From 44d28f500f7f21fa13f0e9eea87544a6269efde7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Mar 2026 17:03:35 -0700 Subject: [PATCH 001/185] codex-tools: extract shared tool schema parsing (#15923) ## Why `parse_tool_input_schema` and the supporting `JsonSchema` model were living in `core/src/tools/spec.rs`, but they already serve callers outside `codex-core`. Keeping that shared schema parsing logic inside `codex-core` makes the crate boundary harder to reason about and works against the guidance in `AGENTS.md` to avoid growing `codex-core` when reusable code can live elsewhere. This change takes the first extraction step by moving the schema parsing primitive into its own crate while keeping the rest of the tool-spec assembly in `codex-core`. ## What changed - added a new `codex-tools` crate under `codex-rs/tools` - moved the shared tool input schema model and sanitizer/parser into `tools/src/json_schema.rs` - kept `tools/src/lib.rs` exports-only, with the module-level unit tests split into `json_schema_tests.rs` - updated `codex-core` to use `codex-tools::JsonSchema` and re-export `parse_tool_input_schema` - updated `codex-app-server` dynamic tool validation to depend on `codex-tools` directly instead of reaching through `codex-core` - wired the new crate into the Cargo workspace and Bazel build graph --- codex-rs/Cargo.lock | 11 ++ codex-rs/Cargo.toml | 2 + codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 2 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/tools/spec.rs | 185 +----------------- codex-rs/core/src/tools/spec_tests.rs | 1 + codex-rs/tools/BUILD.bazel | 6 + codex-rs/tools/Cargo.toml | 15 ++ codex-rs/tools/README.md | 70 +++++++ codex-rs/tools/src/json_schema.rs | 176 +++++++++++++++++ codex-rs/tools/src/json_schema_tests.rs | 98 ++++++++++ codex-rs/tools/src/lib.rs | 7 + 14 files changed, 394 insertions(+), 183 deletions(-) create mode 100644 codex-rs/tools/BUILD.bazel create mode 100644 codex-rs/tools/Cargo.toml create mode 100644 codex-rs/tools/README.md create mode 100644 codex-rs/tools/src/json_schema.rs create mode 100644 codex-rs/tools/src/json_schema_tests.rs create mode 100644 codex-rs/tools/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9487d74a10..9f62e87eea 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1417,6 +1417,7 @@ dependencies = [ "codex-sandboxing", "codex-shell-command", "codex-state", + "codex-tools", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", @@ -1848,6 +1849,7 @@ dependencies = [ "codex-shell-escalation", "codex-state", "codex-terminal-detection", + "codex-tools", "codex-utils-absolute-path", "codex-utils-cache", "codex-utils-cargo-bin", @@ -2627,6 +2629,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "codex-tools" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "serde", + "serde_json", +] + [[package]] name = "codex-tui" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ba287de5c5..f05355e393 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -51,6 +51,7 @@ members = [ "otel", "tui", "tui_app_server", + "tools", "v8-poc", "utils/absolute-path", "utils/cargo-bin", @@ -150,6 +151,7 @@ codex-skills = { path = "skills" } codex-state = { path = "state" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-terminal-detection = { path = "terminal-detection" } +codex-tools = { path = "tools" } codex-tui = { path = "tui" } codex-tui-app-server = { path = "tui_app_server" } codex-v8-poc = { path = "v8-poc" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index f54bb7ad03..ee12e87acb 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -49,6 +49,7 @@ codex-feedback = { workspace = true } codex-rmcp-client = { workspace = true } codex-sandboxing = { workspace = true } codex-state = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 620a85a095..875eb2b87f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -7903,7 +7903,7 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { return Err(format!("duplicate dynamic tool name: {name}")); } - if let Err(err) = codex_core::parse_tool_input_schema(&tool.input_schema) { + if let Err(err) = codex_tools::parse_tool_input_schema(&tool.input_schema) { return Err(format!( "dynamic tool input schema is not supported for {name}: {err}" )); diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index f64a0a5280..e40066027d 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -52,6 +52,7 @@ codex-rmcp-client = { workspace = true } codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ea2651f92e..f0390c805a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -201,6 +201,7 @@ pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; pub use codex_sandboxing::get_platform_sandbox; +pub use codex_tools::parse_tool_input_schema; pub use compact::content_items_to_text; pub use event_mapping::parse_turn_item; pub use exec_policy::ExecPolicyError; @@ -208,7 +209,6 @@ pub use exec_policy::check_execpolicy_for_warnings; pub use exec_policy::format_exec_policy_error_with_source; pub use exec_policy::load_exec_policy; pub use file_watcher::FileWatcherEvent; -pub use tools::spec::parse_tool_input_schema; pub use turn_metadata::build_turn_metadata_header; pub mod compact; pub mod memory_trace; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 82aba1a8cb..a81bf5a3c3 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -46,6 +46,7 @@ use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +pub use codex_tools::parse_tool_input_schema; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -55,6 +56,8 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::path::PathBuf; +pub type JsonSchema = codex_tools::JsonSchema; + const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str = @@ -539,62 +542,6 @@ fn supports_image_generation(model_info: &ModelInfo) -> bool { model_info.input_modalities.contains(&InputModality::Image) } -/// Generic JSON‑Schema subset needed for our tool definitions -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum JsonSchema { - Boolean { - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - }, - String { - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - }, - /// MCP schema allows "number" | "integer" for Number - #[serde(alias = "integer")] - Number { - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - }, - Array { - items: Box, - - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - }, - Object { - properties: BTreeMap, - #[serde(skip_serializing_if = "Option::is_none")] - required: Option>, - #[serde( - rename = "additionalProperties", - skip_serializing_if = "Option::is_none" - )] - additional_properties: Option, - }, -} - -/// Whether additional properties are allowed, and if so, any required schema -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(untagged)] -pub enum AdditionalProperties { - Boolean(bool), - Schema(Box), -} - -impl From for AdditionalProperties { - fn from(b: bool) -> Self { - Self::Boolean(b) - } -} - -impl From for AdditionalProperties { - fn from(s: JsonSchema) -> Self { - Self::Schema(Box::new(s)) - } -} - fn create_network_permissions_schema() -> JsonSchema { JsonSchema::Object { properties: BTreeMap::from([( @@ -2458,13 +2405,6 @@ fn dynamic_tool_to_openai_tool( }) } -/// Parse the tool input_schema or return an error for invalid schema -pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { - let mut input_schema = input_schema.clone(); - sanitize_json_schema(&mut input_schema); - serde_json::from_value::(input_schema) -} - fn mcp_tool_to_openai_tool_parts( tool: rmcp::model::Tool, ) -> Result<(String, JsonSchema, Option), serde_json::Error> { @@ -2489,13 +2429,7 @@ fn mcp_tool_to_openai_tool_parts( ); } - // Serialize to a raw JSON value so we can sanitize schemas coming from MCP - // servers. Some servers omit the top-level or nested `type` in JSON - // Schemas (e.g. using enum/anyOf), or use unsupported variants like - // `integer`. Our internal JsonSchema is a small subset and requires - // `type`, so we coerce/sanitize here for compatibility. - sanitize_json_schema(&mut serialized_input_schema); - let input_schema = serde_json::from_value::(serialized_input_schema)?; + let input_schema = parse_tool_input_schema(&serialized_input_schema)?; let structured_content_schema = output_schema .map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone())) .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())); @@ -2526,117 +2460,6 @@ fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> J }) } -/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited -/// JsonSchema enum. This function: -/// - Ensures every schema object has a "type". If missing, infers it from -/// common keywords (properties => object, items => array, enum/const/format => string) -/// and otherwise defaults to "string". -/// - Fills required child fields (e.g. array items, object properties) with -/// permissive defaults when absent. -fn sanitize_json_schema(value: &mut JsonValue) { - match value { - JsonValue::Bool(_) => { - // JSON Schema boolean form: true/false. Coerce to an accept-all string. - *value = json!({ "type": "string" }); - } - JsonValue::Array(arr) => { - for v in arr.iter_mut() { - sanitize_json_schema(v); - } - } - JsonValue::Object(map) => { - // First, recursively sanitize known nested schema holders - if let Some(props) = map.get_mut("properties") - && let Some(props_map) = props.as_object_mut() - { - for (_k, v) in props_map.iter_mut() { - sanitize_json_schema(v); - } - } - if let Some(items) = map.get_mut("items") { - sanitize_json_schema(items); - } - // Some schemas use oneOf/anyOf/allOf - sanitize their entries - for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] { - if let Some(v) = map.get_mut(combiner) { - sanitize_json_schema(v); - } - } - - // Normalize/ensure type - let mut ty = map.get("type").and_then(|v| v.as_str()).map(str::to_string); - - // If type is an array (union), pick first supported; else leave to inference - if ty.is_none() - && let Some(JsonValue::Array(types)) = map.get("type") - { - for t in types { - if let Some(tt) = t.as_str() - && matches!( - tt, - "object" | "array" | "string" | "number" | "integer" | "boolean" - ) - { - ty = Some(tt.to_string()); - break; - } - } - } - - // Infer type if still missing - if ty.is_none() { - if map.contains_key("properties") - || map.contains_key("required") - || map.contains_key("additionalProperties") - { - ty = Some("object".to_string()); - } else if map.contains_key("items") || map.contains_key("prefixItems") { - ty = Some("array".to_string()); - } else if map.contains_key("enum") - || map.contains_key("const") - || map.contains_key("format") - { - ty = Some("string".to_string()); - } else if map.contains_key("minimum") - || map.contains_key("maximum") - || map.contains_key("exclusiveMinimum") - || map.contains_key("exclusiveMaximum") - || map.contains_key("multipleOf") - { - ty = Some("number".to_string()); - } - } - // If we still couldn't infer, default to string - let ty = ty.unwrap_or_else(|| "string".to_string()); - map.insert("type".to_string(), JsonValue::String(ty.to_string())); - - // Ensure object schemas have properties map - if ty == "object" { - if !map.contains_key("properties") { - map.insert( - "properties".to_string(), - JsonValue::Object(serde_json::Map::new()), - ); - } - // If additionalProperties is an object schema, sanitize it too. - // Leave booleans as-is, since JSON Schema allows boolean here. - if let Some(ap) = map.get_mut("additionalProperties") { - let is_bool = matches!(ap, JsonValue::Bool(_)); - if !is_bool { - sanitize_json_schema(ap); - } - } - } - - // Ensure array schemas have items - if ty == "array" && !map.contains_key("items") { - map.insert("items".to_string(), json!({ "type": "string" })); - } - } - _ => {} - } -} - /// Builds the tool registry builder while collecting tool specs for later serialization. #[cfg(test)] pub(crate) fn build_specs( diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 6f0e7496b8..82310e8726 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::AppInfo; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; +use codex_tools::AdditionalProperties; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::path::PathBuf; diff --git a/codex-rs/tools/BUILD.bazel b/codex-rs/tools/BUILD.bazel new file mode 100644 index 0000000000..d2e730cfa9 --- /dev/null +++ b/codex-rs/tools/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "tools", + crate_name = "codex_tools", +) diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml new file mode 100644 index 0000000000..c596520c57 --- /dev/null +++ b/codex-rs/tools/Cargo.toml @@ -0,0 +1,15 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-tools" +version.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md new file mode 100644 index 0000000000..d4c2f28e91 --- /dev/null +++ b/codex-rs/tools/README.md @@ -0,0 +1,70 @@ +# codex-tools + +`codex-tools` is intended to become the home for tool-related code that is +shared across multiple crates and does not need to stay coupled to +`codex-core`. + +Today this crate is intentionally small. It only owns the shared tool input +schema model and parser that were previously defined in `core/src/tools/spec.rs`: + +- `JsonSchema` +- `AdditionalProperties` +- `parse_tool_input_schema()` + +That extraction is the first step in a longer migration. The goal is not to +move all of `core/src/tools` into this crate in one shot. Instead, the plan is +to peel off reusable pieces in reviewable increments while keeping +compatibility-sensitive orchestration in `codex-core` until the surrounding +boundaries are ready. + +## Vision + +Over time, this crate should hold tool-facing primitives that are shared by +multiple consumers, for example: + +- schema and spec data models +- tool input/output parsing helpers +- tool metadata and compatibility shims that do not depend on `codex-core` +- other narrowly scoped utility code that multiple crates need + +The corresponding non-goals are just as important: + +- do not move `codex-core` orchestration here prematurely +- do not pull `Session` / `TurnContext` / approval flow / runtime execution + logic into this crate unless those dependencies have first been split into + stable shared interfaces +- do not turn this crate into a grab-bag for unrelated helper code + +## Migration approach + +The expected migration shape is: + +1. Move low-coupling tool primitives here. +2. Switch non-core consumers to depend on `codex-tools` directly. +3. Leave compatibility-sensitive adapters in `codex-core` while downstream + call sites are updated. +4. Only extract higher-level tool infrastructure after the crate boundaries are + clear and independently testable. + +That means it is normal for `codex-core` to temporarily re-export types or +helpers from `codex-tools` during the transition. + +## Crate conventions + +This crate should start with stricter structure than `core/src/tools` so it +stays easy to grow: + +- `src/lib.rs` should remain exports-only. +- Business logic should live in named module files such as `foo.rs`. +- Unit tests for `foo.rs` should live in a sibling `foo_tests.rs`. +- The implementation file should wire tests with: + +```rust +#[cfg(test)] +#[path = "foo_tests.rs"] +mod tests; +``` + +If this crate starts accumulating code that needs runtime state from +`codex-core`, that is a sign to revisit the extraction boundary before adding +more here. diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs new file mode 100644 index 0000000000..727a085af1 --- /dev/null +++ b/codex-rs/tools/src/json_schema.rs @@ -0,0 +1,176 @@ +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; + +/// Generic JSON-Schema subset needed for our tool definitions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum JsonSchema { + Boolean { + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + }, + String { + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + }, + /// MCP schema allows "number" | "integer" for Number. + #[serde(alias = "integer")] + Number { + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + }, + Array { + items: Box, + + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + }, + Object { + properties: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + required: Option>, + #[serde( + rename = "additionalProperties", + skip_serializing_if = "Option::is_none" + )] + additional_properties: Option, + }, +} + +/// Whether additional properties are allowed, and if so, any required schema. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum AdditionalProperties { + Boolean(bool), + Schema(Box), +} + +impl From for AdditionalProperties { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +impl From for AdditionalProperties { + fn from(value: JsonSchema) -> Self { + Self::Schema(Box::new(value)) + } +} + +/// Parse the tool `input_schema` or return an error for invalid schema. +pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { + let mut input_schema = input_schema.clone(); + sanitize_json_schema(&mut input_schema); + serde_json::from_value::(input_schema) +} + +/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited +/// JsonSchema enum. This function: +/// - Ensures every schema object has a "type". If missing, infers it from +/// common keywords (properties => object, items => array, enum/const/format => string) +/// and otherwise defaults to "string". +/// - Fills required child fields (e.g. array items, object properties) with +/// permissive defaults when absent. +fn sanitize_json_schema(value: &mut JsonValue) { + match value { + JsonValue::Bool(_) => { + // JSON Schema boolean form: true/false. Coerce to an accept-all string. + *value = json!({ "type": "string" }); + } + JsonValue::Array(values) => { + for value in values { + sanitize_json_schema(value); + } + } + JsonValue::Object(map) => { + if let Some(properties) = map.get_mut("properties") + && let Some(properties_map) = properties.as_object_mut() + { + for value in properties_map.values_mut() { + sanitize_json_schema(value); + } + } + if let Some(items) = map.get_mut("items") { + sanitize_json_schema(items); + } + for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] { + if let Some(value) = map.get_mut(combiner) { + sanitize_json_schema(value); + } + } + + let mut schema_type = map + .get("type") + .and_then(|value| value.as_str()) + .map(str::to_string); + + if schema_type.is_none() + && let Some(JsonValue::Array(types)) = map.get("type") + { + for candidate in types { + if let Some(candidate_type) = candidate.as_str() + && matches!( + candidate_type, + "object" | "array" | "string" | "number" | "integer" | "boolean" + ) + { + schema_type = Some(candidate_type.to_string()); + break; + } + } + } + + if schema_type.is_none() { + if map.contains_key("properties") + || map.contains_key("required") + || map.contains_key("additionalProperties") + { + schema_type = Some("object".to_string()); + } else if map.contains_key("items") || map.contains_key("prefixItems") { + schema_type = Some("array".to_string()); + } else if map.contains_key("enum") + || map.contains_key("const") + || map.contains_key("format") + { + schema_type = Some("string".to_string()); + } else if map.contains_key("minimum") + || map.contains_key("maximum") + || map.contains_key("exclusiveMinimum") + || map.contains_key("exclusiveMaximum") + || map.contains_key("multipleOf") + { + schema_type = Some("number".to_string()); + } + } + + let schema_type = schema_type.unwrap_or_else(|| "string".to_string()); + map.insert("type".to_string(), JsonValue::String(schema_type.clone())); + + if schema_type == "object" { + if !map.contains_key("properties") { + map.insert( + "properties".to_string(), + JsonValue::Object(serde_json::Map::new()), + ); + } + if let Some(additional_properties) = map.get_mut("additionalProperties") + && !matches!(additional_properties, JsonValue::Bool(_)) + { + sanitize_json_schema(additional_properties); + } + } + + if schema_type == "array" && !map.contains_key("items") { + map.insert("items".to_string(), json!({ "type": "string" })); + } + } + _ => {} + } +} + +#[cfg(test)] +#[path = "json_schema_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs new file mode 100644 index 0000000000..7d2cd25c9d --- /dev/null +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -0,0 +1,98 @@ +use super::AdditionalProperties; +use super::JsonSchema; +use super::parse_tool_input_schema; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +#[test] +fn parse_tool_input_schema_coerces_boolean_schemas() { + let schema = parse_tool_input_schema(&serde_json::json!(true)).expect("parse schema"); + + assert_eq!(schema, JsonSchema::String { description: None }); +} + +#[test] +fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "properties": { + "query": {"description": "search query"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::Object { + properties: BTreeMap::from([( + "query".to_string(), + JsonSchema::String { + description: Some("search query".to_string()), + }, + )]), + required: None, + additional_properties: None, + } + ); +} + +#[test] +fn parse_tool_input_schema_normalizes_integer_and_missing_array_items() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "properties": { + "page": {"type": "integer"}, + "tags": {"type": "array"} + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::Object { + properties: BTreeMap::from([ + ("page".to_string(), JsonSchema::Number { description: None },), + ( + "tags".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: None, + }, + ), + ]), + required: None, + additional_properties: None, + } + ); +} + +#[test] +fn parse_tool_input_schema_sanitizes_additional_properties_schema() { + let schema = parse_tool_input_schema(&serde_json::json!({ + "type": "object", + "additionalProperties": { + "required": ["value"], + "properties": { + "value": {"anyOf": [{"type": "string"}, {"type": "number"}]} + } + } + })) + .expect("parse schema"); + + assert_eq!( + schema, + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(AdditionalProperties::Schema(Box::new( + JsonSchema::Object { + properties: BTreeMap::from([( + "value".to_string(), + JsonSchema::String { description: None }, + )]), + required: Some(vec!["value".to_string()]), + additional_properties: None, + }, + ))), + } + ); +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs new file mode 100644 index 0000000000..90d3af10c8 --- /dev/null +++ b/codex-rs/tools/src/lib.rs @@ -0,0 +1,7 @@ +//! Shared tool-schema parsing primitives that can live outside `codex-core`. + +mod json_schema; + +pub use json_schema::AdditionalProperties; +pub use json_schema::JsonSchema; +pub use json_schema::parse_tool_input_schema; From e6e2999209a9a0c3c25a4d2f37aa73d0c400862e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Mar 2026 17:12:45 -0700 Subject: [PATCH 002/185] permissions: remove macOS seatbelt extension profiles (#15918) ## Why `PermissionProfile` should only describe the per-command permissions we still want to grant dynamically. Keeping `MacOsSeatbeltProfileExtensions` in that surface forced extra macOS-only approval, protocol, schema, and TUI branches for a capability we no longer want to expose. ## What changed - Removed the macOS-specific permission-profile types from `codex-protocol`, the app-server v2 API, and the generated schema/TypeScript artifacts. - Deleted the core and sandboxing plumbing that threaded `MacOsSeatbeltProfileExtensions` through execution requests and seatbelt construction. - Simplified macOS seatbelt generation so it always includes the fixed read-only preferences allowlist instead of carrying a configurable profile extension. - Removed the macOS additional-permissions UI/docs/test coverage and deleted the obsolete macOS permission modules. - Tightened `request_permissions` intersection handling so explicitly empty requested read lists are preserved only when that field was actually granted, avoiding zero-grant responses being stored as active permissions. --- codex-rs/Cargo.lock | 1 - ...CommandExecutionRequestApprovalParams.json | 88 ------ .../schema/json/ServerRequest.json | 88 ------ .../codex_app_server_protocol.schemas.json | 88 ------ .../typescript/MacOsAutomationPermission.ts | 5 - .../typescript/MacOsContactsPermission.ts | 5 - .../typescript/MacOsPreferencesPermission.ts | 5 - .../schema/typescript/index.ts | 3 - .../v2/AdditionalMacOsPermissions.ts | 8 - .../v2/AdditionalPermissionProfile.ts | 3 +- .../schema/typescript/v2/index.ts | 1 - .../src/protocol/common.rs | 1 - .../app-server-protocol/src/protocol/v2.rs | 137 +-------- codex-rs/app-server/README.md | 4 +- codex-rs/app-server/src/transport/mod.rs | 3 - codex-rs/cli/src/debug_sandbox.rs | 5 +- codex-rs/core/README.md | 28 +- codex-rs/core/src/codex_tests_guardian.rs | 3 +- codex-rs/core/src/config/config_tests.rs | 4 - codex-rs/core/src/config/mod.rs | 5 - codex-rs/core/src/exec.rs | 2 - codex-rs/core/src/exec_policy_tests.rs | 27 ++ codex-rs/core/src/seatbelt.rs | 5 +- codex-rs/core/src/tools/handlers/mod.rs | 8 +- codex-rs/core/src/tools/js_repl/mod.rs | 2 - .../tools/runtimes/shell/unix_escalation.rs | 42 --- .../runtimes/shell/unix_escalation_tests.rs | 219 ------------- codex-rs/core/src/tools/sandboxing.rs | 2 - .../core/tests/suite/request_permissions.rs | 4 +- codex-rs/protocol/src/approvals.rs | 2 - codex-rs/protocol/src/models.rs | 287 +----------------- codex-rs/protocol/src/request_permissions.rs | 1 - codex-rs/sandboxing/Cargo.toml | 1 - codex-rs/sandboxing/src/lib.rs | 3 - codex-rs/sandboxing/src/macos_permissions.rs | 154 ---------- .../sandboxing/src/macos_permissions_tests.rs | 121 -------- codex-rs/sandboxing/src/manager.rs | 23 +- codex-rs/sandboxing/src/manager_tests.rs | 7 - codex-rs/sandboxing/src/policy_transforms.rs | 65 ++-- .../sandboxing/src/policy_transforms_tests.rs | 199 +++--------- codex-rs/sandboxing/src/seatbelt.rs | 32 +- .../sandboxing/src/seatbelt_permissions.rs | 190 ------------ .../src/seatbelt_permissions_tests.rs | 154 ---------- codex-rs/sandboxing/src/seatbelt_tests.rs | 154 ++-------- .../tui/src/bottom_pane/approval_overlay.rs | 86 ------ ...y_additional_permissions_macos_prompt.snap | 18 -- codex-rs/tui_app_server/src/app.rs | 2 - .../src/bottom_pane/approval_overlay.rs | 86 ------ ...y_additional_permissions_macos_prompt.snap | 18 -- ...y_additional_permissions_macos_prompt.snap | 18 -- 50 files changed, 148 insertions(+), 2269 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/MacOsContactsPermission.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts delete mode 100644 codex-rs/sandboxing/src/macos_permissions.rs delete mode 100644 codex-rs/sandboxing/src/macos_permissions_tests.rs delete mode 100644 codex-rs/sandboxing/src/seatbelt_permissions.rs delete mode 100644 codex-rs/sandboxing/src/seatbelt_permissions_tests.rs delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap delete mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap delete mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9f62e87eea..f494c8528b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2505,7 +2505,6 @@ dependencies = [ "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", - "dirs", "dunce", "libc", "pretty_assertions", diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 66dccf15e0..617fa1f3cb 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -28,41 +28,6 @@ }, "type": "object" }, - "AdditionalMacOsPermissions": { - "properties": { - "accessibility": { - "type": "boolean" - }, - "automations": { - "$ref": "#/definitions/MacOsAutomationPermission" - }, - "calendar": { - "type": "boolean" - }, - "contacts": { - "$ref": "#/definitions/MacOsContactsPermission" - }, - "launchServices": { - "type": "boolean" - }, - "preferences": { - "$ref": "#/definitions/MacOsPreferencesPermission" - }, - "reminders": { - "type": "boolean" - } - }, - "required": [ - "accessibility", - "automations", - "calendar", - "contacts", - "launchServices", - "preferences", - "reminders" - ], - "type": "object" - }, "AdditionalNetworkPermissions": { "properties": { "enabled": { @@ -86,16 +51,6 @@ } ] }, - "macos": { - "anyOf": [ - { - "$ref": "#/definitions/AdditionalMacOsPermissions" - }, - { - "type": "null" - } - ] - }, "network": { "anyOf": [ { @@ -298,49 +253,6 @@ } ] }, - "MacOsAutomationPermission": { - "oneOf": [ - { - "enum": [ - "none", - "all" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "bundle_ids": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "bundle_ids" - ], - "title": "BundleIdsMacOsAutomationPermission", - "type": "object" - } - ] - }, - "MacOsContactsPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, - "MacOsPreferencesPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, "NetworkApprovalContext": { "properties": { "host": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 6c63d36a37..7c11a4c02b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -28,41 +28,6 @@ }, "type": "object" }, - "AdditionalMacOsPermissions": { - "properties": { - "accessibility": { - "type": "boolean" - }, - "automations": { - "$ref": "#/definitions/MacOsAutomationPermission" - }, - "calendar": { - "type": "boolean" - }, - "contacts": { - "$ref": "#/definitions/MacOsContactsPermission" - }, - "launchServices": { - "type": "boolean" - }, - "preferences": { - "$ref": "#/definitions/MacOsPreferencesPermission" - }, - "reminders": { - "type": "boolean" - } - }, - "required": [ - "accessibility", - "automations", - "calendar", - "contacts", - "launchServices", - "preferences", - "reminders" - ], - "type": "object" - }, "AdditionalNetworkPermissions": { "properties": { "enabled": { @@ -86,16 +51,6 @@ } ] }, - "macos": { - "anyOf": [ - { - "$ref": "#/definitions/AdditionalMacOsPermissions" - }, - { - "type": "null" - } - ] - }, "network": { "anyOf": [ { @@ -627,49 +582,6 @@ ], "type": "object" }, - "MacOsAutomationPermission": { - "oneOf": [ - { - "enum": [ - "none", - "all" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "bundle_ids": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "bundle_ids" - ], - "title": "BundleIdsMacOsAutomationPermission", - "type": "object" - } - ] - }, - "MacOsContactsPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, - "MacOsPreferencesPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, "McpElicitationArrayType": { "enum": [ "array" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 28be3e5b35..f52faf5b15 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -28,41 +28,6 @@ }, "type": "object" }, - "AdditionalMacOsPermissions": { - "properties": { - "accessibility": { - "type": "boolean" - }, - "automations": { - "$ref": "#/definitions/MacOsAutomationPermission" - }, - "calendar": { - "type": "boolean" - }, - "contacts": { - "$ref": "#/definitions/MacOsContactsPermission" - }, - "launchServices": { - "type": "boolean" - }, - "preferences": { - "$ref": "#/definitions/MacOsPreferencesPermission" - }, - "reminders": { - "type": "boolean" - } - }, - "required": [ - "accessibility", - "automations", - "calendar", - "contacts", - "launchServices", - "preferences", - "reminders" - ], - "type": "object" - }, "AdditionalNetworkPermissions": { "properties": { "enabled": { @@ -86,16 +51,6 @@ } ] }, - "macos": { - "anyOf": [ - { - "$ref": "#/definitions/AdditionalMacOsPermissions" - }, - { - "type": "null" - } - ] - }, "network": { "anyOf": [ { @@ -2468,49 +2423,6 @@ "title": "JSONRPCResponse", "type": "object" }, - "MacOsAutomationPermission": { - "oneOf": [ - { - "enum": [ - "none", - "all" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "bundle_ids": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "bundle_ids" - ], - "title": "BundleIdsMacOsAutomationPermission", - "type": "object" - } - ] - }, - "MacOsContactsPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, - "MacOsPreferencesPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, "McpElicitationArrayType": { "enum": [ "array" diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts b/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts deleted file mode 100644 index 31036b23ee..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 MacOsAutomationPermission = "none" | "all" | { "bundle_ids": Array }; diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsContactsPermission.ts b/codex-rs/app-server-protocol/schema/typescript/MacOsContactsPermission.ts deleted file mode 100644 index dd6d7b59ef..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsContactsPermission.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 MacOsContactsPermission = "none" | "read_only" | "read_write"; diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts b/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts deleted file mode 100644 index 2f5234a268..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 MacOsPreferencesPermission = "none" | "read_only" | "read_write"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 777feaa56e..09c388337f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -41,9 +41,6 @@ export type { InputModality } from "./InputModality"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; -export type { MacOsAutomationPermission } from "./MacOsAutomationPermission"; -export type { MacOsContactsPermission } from "./MacOsContactsPermission"; -export type { MacOsPreferencesPermission } from "./MacOsPreferencesPermission"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts deleted file mode 100644 index 177661bb0e..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts +++ /dev/null @@ -1,8 +0,0 @@ -// 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 { MacOsAutomationPermission } from "../MacOsAutomationPermission"; -import type { MacOsContactsPermission } from "../MacOsContactsPermission"; -import type { MacOsPreferencesPermission } from "../MacOsPreferencesPermission"; - -export type AdditionalMacOsPermissions = { preferences: MacOsPreferencesPermission, automations: MacOsAutomationPermission, launchServices: boolean, accessibility: boolean, calendar: boolean, reminders: boolean, contacts: MacOsContactsPermission, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts index 701ba54316..65836c119d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts @@ -2,7 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; -import type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions"; import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; -export type AdditionalPermissionProfile = { network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, macos: AdditionalMacOsPermissions | null, }; +export type AdditionalPermissionProfile = { network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index b545f7986f..73fe05eaeb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,7 +5,6 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; -export type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions"; export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4492a14944..f3e56f4ffe 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1703,7 +1703,6 @@ mod tests { read: Some(vec![absolute_path("/tmp/allowed")]), write: None, }), - macos: None, }), proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 559d825aa5..2211e7aa15 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -32,10 +32,6 @@ use codex_protocol::mcp::Tool as McpTool; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::MacOsAutomationPermission as CoreMacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission as CoreMacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission as CoreMacOsPreferencesPermission; -use codex_protocol::models::MacOsSeatbeltProfileExtensions as CoreMacOsSeatbeltProfileExtensions; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; @@ -1085,47 +1081,6 @@ impl From for CoreFileSystemPermissions { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalMacOsPermissions { - pub preferences: CoreMacOsPreferencesPermission, - pub automations: CoreMacOsAutomationPermission, - pub launch_services: bool, - pub accessibility: bool, - pub calendar: bool, - pub reminders: bool, - pub contacts: CoreMacOsContactsPermission, -} - -impl From for AdditionalMacOsPermissions { - fn from(value: CoreMacOsSeatbeltProfileExtensions) -> Self { - Self { - preferences: value.macos_preferences, - automations: value.macos_automation, - launch_services: value.macos_launch_services, - accessibility: value.macos_accessibility, - calendar: value.macos_calendar, - reminders: value.macos_reminders, - contacts: value.macos_contacts, - } - } -} - -impl From for CoreMacOsSeatbeltProfileExtensions { - fn from(value: AdditionalMacOsPermissions) -> Self { - Self { - macos_preferences: value.preferences, - macos_automation: value.automations, - macos_launch_services: value.launch_services, - macos_accessibility: value.accessibility, - macos_calendar: value.calendar, - macos_reminders: value.reminders, - macos_contacts: value.contacts, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1182,7 +1137,6 @@ impl From for CoreRequestPermissionProfile { pub struct AdditionalPermissionProfile { pub network: Option, pub file_system: Option, - pub macos: Option, } impl From for AdditionalPermissionProfile { @@ -1190,7 +1144,6 @@ impl From for AdditionalPermissionProfile { Self { network: value.network.map(AdditionalNetworkPermissions::from), file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - macos: value.macos.map(AdditionalMacOsPermissions::from), } } } @@ -1200,7 +1153,6 @@ impl From for CorePermissionProfile { Self { network: value.network.map(CoreNetworkPermissions::from), file_system: value.file_system.map(CoreFileSystemPermissions::from), - macos: value.macos.map(CoreMacOsSeatbeltProfileExtensions::from), } } } @@ -1222,7 +1174,6 @@ impl From for CorePermissionProfile { Self { network: value.network.map(CoreNetworkPermissions::from), file_system: value.file_system.map(CoreFileSystemPermissions::from), - macos: None, } } } @@ -6075,8 +6026,7 @@ mod tests { "fileSystem": { "read": ["relative/path"], "write": null - }, - "macos": null + } }, "proposedExecpolicyAmendment": null, "proposedNetworkPolicyAmendments": null, @@ -6090,90 +6040,6 @@ mod tests { ); } - #[test] - fn command_execution_request_approval_accepts_macos_automation_bundle_ids_object() { - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "command": "cat file", - "cwd": "/tmp", - "commandActions": null, - "reason": null, - "networkApprovalContext": null, - "additionalPermissions": { - "network": null, - "fileSystem": null, - "macos": { - "preferences": "read_only", - "automations": { - "bundle_ids": ["com.apple.Notes"] - }, - "launchServices": false, - "accessibility": false, - "calendar": false, - "reminders": false, - "contacts": "read_only" - } - }, - "proposedExecpolicyAmendment": null, - "proposedNetworkPolicyAmendments": null, - "availableDecisions": null - })) - .expect("bundle_ids object should deserialize"); - - assert_eq!( - params - .additional_permissions - .and_then(|permissions| permissions.macos) - .map(|macos| (macos.automations, macos.launch_services, macos.contacts)), - Some(( - CoreMacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string(),]), - false, - CoreMacOsContactsPermission::ReadOnly, - )) - ); - } - - #[test] - fn command_execution_request_approval_accepts_macos_reminders_permission() { - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "command": "cat file", - "cwd": "/tmp", - "commandActions": null, - "reason": null, - "networkApprovalContext": null, - "additionalPermissions": { - "network": null, - "fileSystem": null, - "macos": { - "preferences": "read_only", - "automations": "none", - "launchServices": false, - "accessibility": false, - "calendar": false, - "reminders": true, - "contacts": "none" - } - }, - "proposedExecpolicyAmendment": null, - "proposedNetworkPolicyAmendments": null, - "availableDecisions": null - })) - .expect("reminders permission should deserialize"); - - assert_eq!( - params - .additional_permissions - .and_then(|permissions| permissions.macos) - .map(|macos| macos.reminders), - Some(true) - ); - } - #[test] fn permissions_request_approval_uses_request_permission_profile() { let read_only_path = if cfg!(windows) { @@ -6331,7 +6197,6 @@ mod tests { .expect("path must be absolute"), ]), }), - macos: None, } ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1ca4688272..cabea022a1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1019,7 +1019,7 @@ the client can offer session-scoped and/or persistent approval choices. ### Permission requests -The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the standalone tool's narrower permission shape, so it can request network access and additional filesystem access but does not include the broader `macos` branch used by command-execution `additionalPermissions`. +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. ```json { @@ -1055,7 +1055,7 @@ The client responds with `result.permissions`, which should be the granted subse } ``` -Only the granted subset matters on the wire. Any permissions omitted from `result.permissions` are treated as denied, including omitted nested keys inside `result.permissions.macos`, so a sparse response like `{ "permissions": { "macos": { "accessibility": true } } }` grants only accessibility. Any permissions not present in the original request are ignored by the server. +Only the granted subset matters on the wire. Any permissions omitted from `result.permissions` are treated as denied. Any permissions not present in the original request are ignored by the server. Within the same turn, granted permissions are sticky: later shell-like tool calls can automatically reuse the granted subset without reissuing a separate permission request. diff --git a/codex-rs/app-server/src/transport/mod.rs b/codex-rs/app-server/src/transport/mod.rs index 9e02e77239..c0653b903b 100644 --- a/codex-rs/app-server/src/transport/mod.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -801,7 +801,6 @@ mod tests { write: None, }, ), - macos: None, }, ), proposed_execpolicy_amendment: None, @@ -864,7 +863,6 @@ mod tests { write: None, }, ), - macos: None, }, ), proposed_execpolicy_amendment: None, @@ -891,7 +889,6 @@ mod tests { "read": [allowed_path], "write": null, }, - "macos": null, }) ); } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index d519bd5f67..e5cccfc53a 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -18,7 +18,7 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; #[cfg(target_os = "macos")] -use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; use tokio::process::Command as TokioCommand; @@ -246,14 +246,13 @@ async fn run_command_under_sandbox( let mut child = match sandbox_type { #[cfg(target_os = "macos")] SandboxType::Seatbelt => { - let args = create_seatbelt_command_args_for_policies_with_extensions( + let args = create_seatbelt_command_args_for_policies( command, &config.permissions.file_system_sandbox_policy, config.permissions.network_sandbox_policy, sandbox_policy_cwd.as_path(), /*enforce_managed_network*/ false, network.as_ref(), - /*extensions*/ None, ); let network_policy = config.permissions.network_sandbox_policy; spawn_debug_sandbox_child( diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 1605d28a07..63cd3b6f36 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -17,32 +17,8 @@ pointer file), the resolved `gitdir:` target, and `.codex` read-only. Network access and filesystem read/write roots are controlled by `SandboxPolicy`. Seatbelt consumes the resolved policy and enforces it. -Seatbelt also supports macOS permission-profile extensions layered on top of -`SandboxPolicy`: - -- no extension profile provided: - keeps legacy default preferences read access (`user-preference-read`). -- extension profile provided with no `macos_preferences` grant: - does not add preferences access clauses. -- `macos_preferences = "readonly"`: - enables cfprefs read clauses and `user-preference-read`. -- `macos_preferences = "readwrite"`: - includes readonly clauses plus `user-preference-write` and cfprefs shm write - clauses. -- `macos_automation = true`: - enables broad Apple Events send permissions. -- `macos_automation = ["com.apple.Notes", ...]`: - enables Apple Events send only to listed bundle IDs. -- `macos_launch_services = true`: - enables LaunchServices lookups and open/launch operations. -- `macos_accessibility = true`: - enables `com.apple.axserver` mach lookup. -- `macos_calendar = true`: - enables `com.apple.CalendarAgent` mach lookup. -- `macos_contacts = "read_only"`: - enables Address Book read access and Contacts read services. -- `macos_contacts = "read_write"`: - includes the readonly Contacts clauses plus Address Book writes and keychain/temp helpers required for writes. +Seatbelt also keeps the legacy default preferences read access +(`user-preference-read`) needed for cfprefs-backed macOS behavior. ### Linux diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 33b8d15d19..18eb3d177b 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -160,7 +160,6 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid enabled: Some(true), }), file_system: None, - macos: None, }, "justification": params.justification.clone(), }) @@ -234,7 +233,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic assert_eq!( output, - "missing `additional_permissions`; provide at least one of `network`, `file_system`, or `macos` when using `with_additional_permissions`" + "missing `additional_permissions`; provide at least one of `network` or `file_system` when using `with_additional_permissions`" ); } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index a62919a444..4d6527ab80 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4379,7 +4379,6 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(None), @@ -4522,7 +4521,6 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(None), @@ -4663,7 +4661,6 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(None), @@ -4790,7 +4787,6 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(None), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8435e61325..fa21cbad84 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -78,7 +78,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemSandboxPolicy; @@ -206,9 +205,6 @@ pub struct Permissions { pub windows_sandbox_mode: Option, /// Whether the final Windows sandboxed child should run on a private desktop. pub windows_sandbox_private_desktop: bool, - /// Optional macOS seatbelt extension profile used to extend default - /// seatbelt permissions when running under seatbelt. - pub macos_seatbelt_profile_extensions: Option, } /// Application configuration loaded from disk and merged with overrides. @@ -2556,7 +2552,6 @@ impl Config { shell_environment_policy, windows_sandbox_mode, windows_sandbox_private_desktop, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer, enforce_residency: enforce_residency.value, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 4291b6cca3..11d8600ba1 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -292,8 +292,6 @@ pub fn build_exec_request( enforce_managed_network, network: network.as_ref(), sandbox_policy_cwd: sandbox_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level, diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index f062bcc741..200f302a20 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -828,6 +828,33 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { ); } +#[tokio::test] +async fn exec_approval_requirement_prompts_for_inline_additional_permissions_under_on_request() { + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "zsh".to_string(), + "-lc".to_string(), + "touch requested-dir/requested-but-unused.txt".to_string(), + ], + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + prefix_rule: None, + }, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "touch".to_string(), + "requested-dir/requested-but-unused.txt".to_string(), + ])), + }, + ) + .await; +} + #[tokio::test] async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled() { diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index f81016a990..353c9708f5 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -9,7 +9,7 @@ use codex_network_proxy::NetworkProxy; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; -use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -24,14 +24,13 @@ pub async fn spawn_command_under_seatbelt( network: Option<&NetworkProxy>, mut env: HashMap, ) -> std::io::Result { - let args = create_seatbelt_command_args_for_policies_with_extensions( + let args = create_seatbelt_command_args_for_policies( command, &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, /*enforce_managed_network*/ false, network, - /*extensions*/ None, ); let arg0 = None; env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 3241b323b8..1afab676ab 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -127,18 +127,14 @@ pub(crate) fn normalize_and_validate_additional_permissions( } let Some(additional_permissions) = additional_permissions else { return Err( - "missing `additional_permissions`; provide at least one of `network`, `file_system`, or `macos` when using `with_additional_permissions`" + "missing `additional_permissions`; provide at least one of `network` or `file_system` when using `with_additional_permissions`" .to_string(), ); }; - #[cfg(not(target_os = "macos"))] - if additional_permissions.macos.is_some() { - return Err("`additional_permissions.macos` is only supported on macOS".to_string()); - } let normalized = normalize_additional_permissions(additional_permissions)?; if normalized.is_empty() { return Err( - "`additional_permissions` must include at least one requested permission in `network`, `file_system`, or `macos`" + "`additional_permissions` must include at least one requested permission in `network` or `file_system`" .to_string(), ); } diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index a9e3aa0f20..beb2930b22 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1068,8 +1068,6 @@ impl JsReplManager { enforce_managed_network: has_managed_network_requirements, network: None, sandbox_policy_cwd: &turn.cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock: turn.features.use_legacy_landlock(), windows_sandbox_level: turn.windows_sandbox_level, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 8cfb551a1a..6ceef4c2c7 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -23,7 +23,6 @@ use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; use codex_features::Feature; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -154,12 +153,6 @@ pub(super) async fn try_run_zsh_fork( windows_sandbox_level, arg0, sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(), - macos_seatbelt_profile_extensions: ctx - .turn - .config - .permissions - .macos_seatbelt_profile_extensions - .clone(), codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), }; @@ -258,12 +251,6 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( windows_sandbox_level: exec_request.windows_sandbox_level, arg0: exec_request.arg0.clone(), sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(), - macos_seatbelt_profile_extensions: ctx - .turn - .config - .permissions - .macos_seatbelt_profile_extensions - .clone(), codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), }; @@ -359,7 +346,6 @@ impl CoreShellActionProvider { file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, additional_permissions: Option<&PermissionProfile>, - macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>, ) -> EscalationExecution { match sandbox_permissions { SandboxPermissions::UseDefault => EscalationExecution::TurnDefault, @@ -373,8 +359,6 @@ impl CoreShellActionProvider { sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: file_system_sandbox_policy.clone(), network_sandbox_policy, - macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions - .cloned(), }, )) }) @@ -560,11 +544,6 @@ impl EscalationPolicy for CoreShellActionProvider { &self.file_system_sandbox_policy, self.network_sandbox_policy, self.prompt_permissions.as_ref(), - self.turn - .config - .permissions - .macos_seatbelt_profile_extensions - .as_ref(), ), }; self.process_decision( @@ -687,8 +666,6 @@ struct CoreShellCommandExecutor { windows_sandbox_level: WindowsSandboxLevel, arg0: Option, sandbox_policy_cwd: PathBuf, - #[cfg_attr(not(target_os = "macos"), allow(dead_code))] - macos_seatbelt_profile_extensions: Option, codex_linux_sandbox_exe: Option, use_legacy_landlock: bool, } @@ -701,8 +678,6 @@ struct PrepareSandboxedExecParams<'a> { file_system_sandbox_policy: &'a FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, additional_permissions: Option, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, } #[async_trait::async_trait] @@ -787,17 +762,12 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { file_system_sandbox_policy: &self.file_system_sandbox_policy, network_sandbox_policy: self.network_sandbox_policy, additional_permissions: None, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: self - .macos_seatbelt_profile_extensions - .as_ref(), })? } EscalationExecution::Permissions(EscalationPermissions::PermissionProfile( permission_profile, )) => { // Merge additive permissions into the existing turn/request sandbox policy. - // On macOS, additional profile extensions are unioned with the turn defaults. self.prepare_sandboxed_exec(PrepareSandboxedExecParams { command, workdir, @@ -806,10 +776,6 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { file_system_sandbox_policy: &self.file_system_sandbox_policy, network_sandbox_policy: self.network_sandbox_policy, additional_permissions: Some(permission_profile), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: self - .macos_seatbelt_profile_extensions - .as_ref(), })? } EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => { @@ -822,10 +788,6 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { file_system_sandbox_policy: &permissions.file_system_sandbox_policy, network_sandbox_policy: permissions.network_sandbox_policy, additional_permissions: None, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: permissions - .macos_seatbelt_profile_extensions - .as_ref(), })? } }; @@ -848,8 +810,6 @@ impl CoreShellCommandExecutor { file_system_sandbox_policy, network_sandbox_policy, additional_permissions, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions, } = params; let (program, args) = command .split_first() @@ -882,8 +842,6 @@ impl CoreShellCommandExecutor { enforce_managed_network: self.network.is_some(), network: self.network.as_ref(), sandbox_policy_cwd: &self.sandbox_policy_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(), use_legacy_landlock: self.use_legacy_landlock, windows_sandbox_level: self.windows_sandbox_level, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 46ba0cf908..41cfce5150 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -1,6 +1,4 @@ use super::CoreShellActionProvider; -#[cfg(target_os = "macos")] -use super::CoreShellCommandExecutor; use super::InterceptedExecPolicyContext; use super::ParsedShellCommand; use super::commands_for_intercepted_exec_policy; @@ -8,12 +6,6 @@ use super::evaluate_intercepted_exec_policy; use super::extract_shell_script; use super::join_program_and_argv; use super::map_exec_result; -#[cfg(target_os = "macos")] -use crate::config::Constrained; -#[cfg(target_os = "macos")] -use crate::config::Permissions; -#[cfg(target_os = "macos")] -use crate::config::types::ShellEnvironmentPolicy; use crate::protocol::AskForApproval; use crate::protocol::GranularApprovalConfig; use crate::protocol::ReadOnlyAccess; @@ -23,11 +15,7 @@ use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::PolicyParser; use codex_execpolicy::RuleMatch; -#[cfg(target_os = "macos")] -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; -use codex_protocol::models::MacOsPreferencesPermission; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -36,18 +24,12 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::SandboxType; -#[cfg(target_os = "macos")] -use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; use codex_shell_escalation::Permissions as EscalatedPermissions; -#[cfg(target_os = "macos")] -use codex_shell_escalation::ShellCommandExecutor; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -#[cfg(target_os = "macos")] -use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -76,11 +58,6 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { }]) } -#[cfg(target_os = "macos")] -fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::unrestricted() -} - #[test] fn execve_prompt_rejection_keeps_prefix_rules_on_rules_flag() { assert_eq!( @@ -294,10 +271,6 @@ fn shell_request_escalation_execution_is_explicit() { }, ]); let network_sandbox_policy = NetworkSandboxPolicy::Restricted; - let macos_seatbelt_profile_extensions = MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - ..Default::default() - }; assert_eq!( CoreShellActionProvider::shell_request_escalation_execution( @@ -306,7 +279,6 @@ fn shell_request_escalation_execution_is_explicit() { &file_system_sandbox_policy, network_sandbox_policy, None, - Some(&macos_seatbelt_profile_extensions), ), EscalationExecution::TurnDefault, ); @@ -317,7 +289,6 @@ fn shell_request_escalation_execution_is_explicit() { &file_system_sandbox_policy, network_sandbox_policy, None, - Some(&macos_seatbelt_profile_extensions), ), EscalationExecution::Unsandboxed, ); @@ -328,14 +299,12 @@ fn shell_request_escalation_execution_is_explicit() { &file_system_sandbox_policy, network_sandbox_policy, Some(&requested_permissions), - Some(&macos_seatbelt_profile_extensions), ), EscalationExecution::Permissions(EscalationPermissions::Permissions( EscalatedPermissions { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - macos_seatbelt_profile_extensions: Some(macos_seatbelt_profile_extensions), }, )), ); @@ -558,191 +527,3 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) evaluation.decision )); } - -#[cfg(target_os = "macos")] -#[tokio::test] -async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() { - let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap(); - let executor = CoreShellCommandExecutor { - command: vec!["echo".to_string(), "ok".to_string()], - cwd: cwd.to_path_buf(), - env: HashMap::new(), - network: None, - sandbox: SandboxType::None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), - network_sandbox_policy: NetworkSandboxPolicy::Restricted, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - arg0: None, - sandbox_policy_cwd: cwd.to_path_buf(), - macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - ..Default::default() - }), - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - }; - - let prepared = executor - .prepare_escalated_exec( - &AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(), - &["echo".to_string(), "ok".to_string()], - &cwd, - HashMap::new(), - EscalationExecution::TurnDefault, - ) - .await - .unwrap(); - - assert_eq!( - prepared.command.first().map(String::as_str), - Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - ); - assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p")); - assert!( - prepared - .command - .get(2) - .is_some_and(|policy| policy.contains("(allow user-preference-write)")), - "expected seatbelt policy to include macOS extension profile: {:?}", - prepared.command - ); -} - -#[cfg(target_os = "macos")] -#[tokio::test] -async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() { - let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap(); - let executor = CoreShellCommandExecutor { - command: vec!["echo".to_string(), "ok".to_string()], - cwd: cwd.to_path_buf(), - env: HashMap::new(), - network: None, - sandbox: SandboxType::None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), - network_sandbox_policy: NetworkSandboxPolicy::Enabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - arg0: None, - sandbox_policy_cwd: cwd.to_path_buf(), - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - }; - - let permissions = Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), - network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: false, - macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - ..Default::default() - }), - }; - - let prepared = executor - .prepare_escalated_exec( - &AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(), - &["echo".to_string(), "ok".to_string()], - &cwd, - HashMap::new(), - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { - sandbox_policy: permissions.sandbox_policy.get().clone(), - file_system_sandbox_policy: permissions.file_system_sandbox_policy.clone(), - network_sandbox_policy: permissions.network_sandbox_policy, - macos_seatbelt_profile_extensions: permissions - .macos_seatbelt_profile_extensions - .clone(), - }, - )), - ) - .await - .unwrap(); - - assert_eq!( - prepared.command.first().map(String::as_str), - Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - ); - assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p")); - assert!( - prepared - .command - .get(2) - .is_some_and(|policy| policy.contains("(allow user-preference-write)")), - "expected seatbelt policy to include macOS extension profile: {:?}", - prepared.command - ); -} - -#[cfg(target_os = "macos")] -#[tokio::test] -async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_macos_extensions() { - let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap(); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let executor = CoreShellCommandExecutor { - command: vec!["echo".to_string(), "ok".to_string()], - cwd: cwd.to_path_buf(), - env: HashMap::new(), - network: None, - sandbox: SandboxType::None, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - arg0: None, - sandbox_policy_cwd: cwd.to_path_buf(), - macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - ..Default::default() - }), - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - }; - - let prepared = executor - .prepare_escalated_exec( - &AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(), - &["echo".to_string(), "ok".to_string()], - &cwd, - HashMap::new(), - EscalationExecution::Permissions(EscalationPermissions::PermissionProfile( - PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_calendar: true, - macos_reminders: false, - ..Default::default() - }), - ..Default::default() - }, - )), - ) - .await - .unwrap(); - - let policy = prepared - .command - .get(2) - .expect("seatbelt policy should be present"); - assert_eq!( - prepared.command.first().map(String::as_str), - Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - ); - assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p")); - assert!( - policy.contains("(allow user-preference-read)"), - "expected turn macOS seatbelt extensions to be preserved: {:?}", - prepared.command - ); - assert!( - policy.contains("(allow mach-lookup (global-name \"com.apple.CalendarAgent\"))"), - "expected requested macOS seatbelt extensions to be included: {:?}", - prepared.command - ); -} diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 6cad5a3293..5b7e950192 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -348,8 +348,6 @@ impl<'a> SandboxAttempt<'a> { enforce_managed_network: self.enforce_managed_network, network, sandbox_policy_cwd: self.sandbox_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe, use_legacy_landlock: self.use_legacy_landlock, windows_sandbox_level: self.windows_sandbox_level, diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 7c16599bf0..2cfd1cf6f7 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -4,6 +4,7 @@ use anyhow::Result; use codex_core::config::Constrained; use codex_core::sandboxing::SandboxPermissions; use codex_features::Feature; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -193,7 +194,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, - approvals_reviewer: None, + approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, model: session_model, effort: None, @@ -1579,7 +1580,6 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() .clone() .unwrap_or_else(|| panic!("expected merged additional permissions")); assert_eq!(approval_permissions.network, None); - assert_eq!(approval_permissions.macos, None); let approval_file_system = approval_permissions .file_system diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 95ccdd624c..cd2b3e6952 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -1,5 +1,4 @@ use crate::mcp::RequestId; -use crate::models::MacOsSeatbeltProfileExtensions; use crate::models::PermissionProfile; use crate::parse_command::ParsedCommand; use crate::permissions::FileSystemSandboxPolicy; @@ -20,7 +19,6 @@ pub struct Permissions { pub sandbox_policy: SandboxPolicy, pub file_system_sandbox_policy: FileSystemSandboxPolicy, pub network_sandbox_policy: NetworkSandboxPolicy, - pub macos_seatbelt_profile_extensions: Option, } #[allow(clippy::large_enum_variant)] diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6fa8162bf7..4717907177 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -88,138 +88,15 @@ impl NetworkPermissions { } } -#[derive( - Debug, - Clone, - PartialEq, - Eq, - PartialOrd, - Ord, - Default, - Hash, - Serialize, - Deserialize, - JsonSchema, - TS, -)] -#[serde(rename_all = "snake_case")] -pub enum MacOsPreferencesPermission { - None, - // IMPORTANT: ReadOnly needs to be the default because it's the - // security-sensitive default and keeps cf prefs working. - #[default] - ReadOnly, - ReadWrite, -} - -#[derive( - Debug, - Clone, - PartialEq, - Eq, - PartialOrd, - Ord, - Default, - Hash, - Serialize, - Deserialize, - JsonSchema, - TS, -)] -#[serde(rename_all = "snake_case")] -pub enum MacOsContactsPermission { - #[default] - None, - ReadOnly, - ReadWrite, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "snake_case", try_from = "MacOsAutomationPermissionDe")] -pub enum MacOsAutomationPermission { - #[default] - None, - All, - BundleIds(Vec), -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum MacOsAutomationPermissionDe { - Mode(String), - BundleIds(Vec), - BundleIdsObject { bundle_ids: Vec }, -} - -impl TryFrom for MacOsAutomationPermission { - type Error = String; - - /// Accepts one of: - /// - `"none"` or `"all"` - /// - a plain list of bundle IDs, e.g. `["com.apple.Notes"]` - /// - an object with bundle IDs, e.g. `{"bundle_ids": ["com.apple.Notes"]}` - fn try_from(value: MacOsAutomationPermissionDe) -> Result { - let permission = match value { - MacOsAutomationPermissionDe::Mode(value) => { - let normalized = value.trim().to_ascii_lowercase(); - if normalized == "all" { - MacOsAutomationPermission::All - } else if normalized == "none" { - MacOsAutomationPermission::None - } else { - return Err(format!( - "invalid macOS automation permission: {value}; expected none, all, or bundle ids" - )); - } - } - MacOsAutomationPermissionDe::BundleIds(bundle_ids) - | MacOsAutomationPermissionDe::BundleIdsObject { bundle_ids } => { - let bundle_ids = bundle_ids - .into_iter() - .map(|bundle_id| bundle_id.trim().to_string()) - .filter(|bundle_id| !bundle_id.is_empty()) - .collect::>(); - if bundle_ids.is_empty() { - MacOsAutomationPermission::None - } else { - MacOsAutomationPermission::BundleIds(bundle_ids) - } - } - }; - - Ok(permission) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema, TS)] -#[serde(default)] -pub struct MacOsSeatbeltProfileExtensions { - #[serde(alias = "preferences")] - pub macos_preferences: MacOsPreferencesPermission, - #[serde(alias = "automations")] - pub macos_automation: MacOsAutomationPermission, - #[serde(alias = "launch_services")] - pub macos_launch_services: bool, - #[serde(alias = "accessibility")] - pub macos_accessibility: bool, - #[serde(alias = "calendar")] - pub macos_calendar: bool, - #[serde(alias = "reminders")] - pub macos_reminders: bool, - #[serde(alias = "contacts")] - pub macos_contacts: MacOsContactsPermission, -} - #[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)] pub struct PermissionProfile { pub network: Option, pub file_system: Option, - pub macos: Option, } impl PermissionProfile { pub fn is_empty(&self) -> bool { - self.network.is_none() && self.file_system.is_none() && self.macos.is_none() + self.network.is_none() && self.file_system.is_none() } } @@ -726,7 +603,7 @@ fn granular_prompt_intro_text() -> &'static str { } fn request_permissions_tool_prompt_section() -> &'static str { - "# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it when you need to request additional `network`, `file_system`, or `macos` permissions before later shell-like commands need them. Request only the specific permissions required for the task." + "# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it when you need to request additional `network` or `file_system` permissions before later shell-like commands need them. Request only the specific permissions required for the task." } fn granular_instructions( @@ -1647,170 +1524,10 @@ mod tests { let permission_profile = PermissionProfile { network: Some(NetworkPermissions { enabled: None }), file_system: None, - macos: None, }; assert_eq!(permission_profile.is_empty(), false); } - #[test] - fn macos_preferences_permission_deserializes_read_write() { - let permission = serde_json::from_str::("\"read_write\"") - .expect("deserialize macos preferences permission"); - assert_eq!(permission, MacOsPreferencesPermission::ReadWrite); - } - - #[test] - fn macos_preferences_permission_order_matches_permissiveness() { - assert!(MacOsPreferencesPermission::None < MacOsPreferencesPermission::ReadOnly); - assert!(MacOsPreferencesPermission::ReadOnly < MacOsPreferencesPermission::ReadWrite); - } - - #[test] - fn macos_contacts_permission_order_matches_permissiveness() { - assert!(MacOsContactsPermission::None < MacOsContactsPermission::ReadOnly); - assert!(MacOsContactsPermission::ReadOnly < MacOsContactsPermission::ReadWrite); - } - - #[test] - fn permission_profile_deserializes_macos_seatbelt_profile_extensions() { - let permission_profile = serde_json::from_value::(serde_json::json!({ - "network": null, - "file_system": null, - "macos": { - "macos_preferences": "read_write", - "macos_automation": ["com.apple.Notes"], - "macos_launch_services": true, - "macos_accessibility": true, - "macos_calendar": true - } - })) - .expect("deserialize permission profile"); - - assert_eq!( - permission_profile, - PermissionProfile { - network: None, - file_system: None, - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - } - ); - } - - #[test] - fn permission_profile_deserializes_macos_reminders_permission() { - let permission_profile = serde_json::from_value::(serde_json::json!({ - "macos": { - "macos_reminders": true - } - })) - .expect("deserialize reminders permission profile"); - - assert_eq!( - permission_profile, - PermissionProfile { - network: None, - file_system: None, - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::None, - }), - } - ); - } - - #[test] - fn macos_seatbelt_profile_extensions_deserializes_missing_fields_to_defaults() { - let permissions = - serde_json::from_value::(serde_json::json!({ - "macos_automation": ["com.apple.Notes"] - })) - .expect("deserialize macos permissions"); - - assert_eq!( - permissions, - MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - } - ); - } - - #[test] - fn macos_seatbelt_profile_extensions_deserializes_tool_schema_aliases() { - let permissions = - serde_json::from_value::(serde_json::json!({ - "preferences": "read_write", - "automations": ["com.apple.Notes"], - "launch_services": true, - "accessibility": true, - "calendar": true, - "reminders": true, - "contacts": "read_only" - })) - .expect("deserialize macos permissions"); - - assert_eq!( - permissions, - MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::ReadOnly, - } - ); - } - - #[test] - fn macos_automation_permission_deserializes_all_and_none() { - let all = serde_json::from_str::("\"all\"") - .expect("deserialize all automation permission"); - let none = serde_json::from_str::("\"none\"") - .expect("deserialize none automation permission"); - - assert_eq!(all, MacOsAutomationPermission::All); - assert_eq!(none, MacOsAutomationPermission::None); - } - - #[test] - fn macos_automation_permission_deserializes_bundle_ids_object() { - let permission = serde_json::from_value::(serde_json::json!({ - "bundle_ids": ["com.apple.Notes"] - })) - .expect("deserialize bundle_ids object automation permission"); - - assert_eq!( - permission, - MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string(),]) - ); - } - #[test] fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() { let contents = vec![serde_json::json!({ diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index db5396c5e4..400fde4e20 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -32,7 +32,6 @@ impl From for PermissionProfile { Self { network: value.network, file_system: value.file_system, - macos: None, } } } diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index cd7558ec90..274e5bce22 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -15,7 +15,6 @@ workspace = true codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } -dirs = { workspace = true } dunce = { workspace = true } libc = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index eb6b4e33b8..75bddad5e9 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,13 +1,10 @@ #[cfg(target_os = "linux")] mod bwrap; pub mod landlock; -pub mod macos_permissions; mod manager; pub mod policy_transforms; #[cfg(target_os = "macos")] pub mod seatbelt; -#[cfg(target_os = "macos")] -mod seatbelt_permissions; #[cfg(target_os = "linux")] pub use bwrap::find_system_bwrap_in_path; diff --git a/codex-rs/sandboxing/src/macos_permissions.rs b/codex-rs/sandboxing/src/macos_permissions.rs deleted file mode 100644 index 22314937ef..0000000000 --- a/codex-rs/sandboxing/src/macos_permissions.rs +++ /dev/null @@ -1,154 +0,0 @@ -use std::collections::BTreeSet; - -use codex_protocol::models::MacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; - -/// Merges macOS seatbelt profile extensions by taking the permissive union of -/// each permission field. -pub fn merge_macos_seatbelt_profile_extensions( - base: Option<&MacOsSeatbeltProfileExtensions>, - permissions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Option { - let Some(permissions) = permissions else { - return base.cloned(); - }; - - match base { - Some(base) => Some(MacOsSeatbeltProfileExtensions { - macos_preferences: union_macos_preferences_permission( - &base.macos_preferences, - &permissions.macos_preferences, - ), - macos_automation: union_macos_automation_permission( - &base.macos_automation, - &permissions.macos_automation, - ), - macos_launch_services: base.macos_launch_services || permissions.macos_launch_services, - macos_accessibility: base.macos_accessibility || permissions.macos_accessibility, - macos_calendar: base.macos_calendar || permissions.macos_calendar, - macos_reminders: base.macos_reminders || permissions.macos_reminders, - macos_contacts: union_macos_contacts_permission( - &base.macos_contacts, - &permissions.macos_contacts, - ), - }), - None => Some(permissions.clone()), - } -} - -pub fn intersect_macos_seatbelt_profile_extensions( - requested: Option, - granted: Option, -) -> Option { - match (requested, granted) { - (Some(requested), Some(granted)) => { - let macos_automation = intersect_macos_automation_permission( - &requested.macos_automation, - &granted.macos_automation, - ); - - Some(MacOsSeatbeltProfileExtensions { - macos_preferences: requested.macos_preferences.min(granted.macos_preferences), - macos_automation, - macos_launch_services: requested.macos_launch_services - && granted.macos_launch_services, - macos_accessibility: requested.macos_accessibility && granted.macos_accessibility, - macos_calendar: requested.macos_calendar && granted.macos_calendar, - macos_reminders: requested.macos_reminders && granted.macos_reminders, - macos_contacts: requested.macos_contacts.min(granted.macos_contacts), - }) - } - _ => None, - } -} - -/// Unions two preferences permissions by keeping the more permissive one. -/// -/// The larger rank wins: `None < ReadOnly < ReadWrite`. When both sides have -/// the same rank, this keeps `base`. -fn union_macos_preferences_permission( - base: &MacOsPreferencesPermission, - requested: &MacOsPreferencesPermission, -) -> MacOsPreferencesPermission { - if base < requested { - requested.clone() - } else { - base.clone() - } -} - -fn union_macos_contacts_permission( - base: &MacOsContactsPermission, - requested: &MacOsContactsPermission, -) -> MacOsContactsPermission { - if base < requested { - requested.clone() - } else { - base.clone() - } -} - -/// Unions two automation permissions by keeping the more permissive result. -/// -/// `All` wins over everything, `None` yields to the other side, and two bundle -/// ID allowlists are unioned together. -fn union_macos_automation_permission( - base: &MacOsAutomationPermission, - requested: &MacOsAutomationPermission, -) -> MacOsAutomationPermission { - match (base, requested) { - (MacOsAutomationPermission::All, _) | (_, MacOsAutomationPermission::All) => { - MacOsAutomationPermission::All - } - (MacOsAutomationPermission::None, _) => requested.clone(), - (_, MacOsAutomationPermission::None) => base.clone(), - ( - MacOsAutomationPermission::BundleIds(base_bundle_ids), - MacOsAutomationPermission::BundleIds(requested_bundle_ids), - ) => MacOsAutomationPermission::BundleIds( - base_bundle_ids - .iter() - .chain(requested_bundle_ids.iter()) - .cloned() - .collect::>() - .into_iter() - .collect(), - ), - } -} - -fn intersect_macos_automation_permission( - requested: &MacOsAutomationPermission, - granted: &MacOsAutomationPermission, -) -> MacOsAutomationPermission { - match (requested, granted) { - (_, MacOsAutomationPermission::None) | (MacOsAutomationPermission::None, _) => { - MacOsAutomationPermission::None - } - (MacOsAutomationPermission::All, granted) => granted.clone(), - (MacOsAutomationPermission::BundleIds(requested), MacOsAutomationPermission::All) => { - MacOsAutomationPermission::BundleIds(requested.clone()) - } - ( - MacOsAutomationPermission::BundleIds(requested), - MacOsAutomationPermission::BundleIds(granted), - ) => { - let bundle_ids = requested - .iter() - .filter(|bundle_id| granted.contains(bundle_id)) - .cloned() - .collect::>(); - if bundle_ids.is_empty() { - MacOsAutomationPermission::None - } else { - MacOsAutomationPermission::BundleIds(bundle_ids) - } - } - } -} - -#[cfg(all(test, target_os = "macos"))] -#[path = "macos_permissions_tests.rs"] -mod tests; diff --git a/codex-rs/sandboxing/src/macos_permissions_tests.rs b/codex-rs/sandboxing/src/macos_permissions_tests.rs deleted file mode 100644 index 97a2a2c753..0000000000 --- a/codex-rs/sandboxing/src/macos_permissions_tests.rs +++ /dev/null @@ -1,121 +0,0 @@ -use super::intersect_macos_automation_permission; -use super::intersect_macos_seatbelt_profile_extensions; -use super::merge_macos_seatbelt_profile_extensions; -use super::union_macos_automation_permission; -use super::union_macos_contacts_permission; -use super::union_macos_preferences_permission; -use codex_protocol::models::MacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; -use pretty_assertions::assert_eq; - -#[test] -fn merge_extensions_widens_permissions() { - let base = MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::ReadOnly, - }; - let requested = MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - "com.apple.Calendar".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::ReadWrite, - }; - - let merged = - merge_macos_seatbelt_profile_extensions(Some(&base), Some(&requested)).expect("merge"); - - assert_eq!( - merged, - MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::ReadWrite, - } - ); -} - -#[test] -fn union_macos_preferences_permission_does_not_downgrade() { - let base = MacOsPreferencesPermission::ReadWrite; - let requested = MacOsPreferencesPermission::ReadOnly; - - let merged = union_macos_preferences_permission(&base, &requested); - - assert_eq!(merged, MacOsPreferencesPermission::ReadWrite); -} - -#[test] -fn union_macos_automation_permission_all_is_dominant() { - let base = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]); - let requested = MacOsAutomationPermission::All; - - let merged = union_macos_automation_permission(&base, &requested); - - assert_eq!(merged, MacOsAutomationPermission::All); -} - -#[test] -fn intersect_macos_automation_permission_keeps_common_bundle_ids() { - let requested = MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - "com.apple.Calendar".to_string(), - ]); - let granted = MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]); - - let intersected = intersect_macos_automation_permission(&requested, &granted); - - assert_eq!( - intersected, - MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]) - ); -} - -#[test] -fn intersect_macos_seatbelt_profile_extensions_preserves_default_grant() { - let requested = MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string()]), - macos_launch_services: false, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }; - let granted = MacOsSeatbeltProfileExtensions::default(); - - let intersected = intersect_macos_seatbelt_profile_extensions(Some(requested), Some(granted)); - - assert_eq!(intersected, Some(MacOsSeatbeltProfileExtensions::default())); -} - -#[test] -fn union_macos_contacts_permission_does_not_downgrade() { - let base = MacOsContactsPermission::ReadWrite; - let requested = MacOsContactsPermission::ReadOnly; - - let merged = union_macos_contacts_permission(&base, &requested); - - assert_eq!(merged, MacOsContactsPermission::ReadWrite); -} diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 3f121ba413..c89e7cd9be 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -8,11 +8,9 @@ use crate::policy_transforms::should_require_platform_sandbox; #[cfg(target_os = "macos")] use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; #[cfg(target_os = "macos")] -use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use crate::seatbelt::create_seatbelt_command_args_for_policies; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; -#[cfg(target_os = "macos")] -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -102,8 +100,6 @@ pub struct SandboxTransformRequest<'a> { // to make shared ownership explicit across runtime/sandbox plumbing. pub network: Option<&'a NetworkProxy>, pub sandbox_policy_cwd: &'a Path, - #[cfg(target_os = "macos")] - pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, pub codex_linux_sandbox_exe: Option<&'a PathBuf>, pub use_legacy_landlock: bool, pub windows_sandbox_level: WindowsSandboxLevel, @@ -181,27 +177,15 @@ impl SandboxManager { enforce_managed_network, network, sandbox_policy_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions, codex_linux_sandbox_exe, use_legacy_landlock, windows_sandbox_level, windows_sandbox_private_desktop, } = request; - #[cfg(not(target_os = "macos"))] - let macos_seatbelt_profile_extensions = None; let additional_permissions = command.additional_permissions.take(); let EffectiveSandboxPermissions { sandbox_policy: effective_policy, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: effective_macos_seatbelt_profile_extensions, - #[cfg(not(target_os = "macos"))] - macos_seatbelt_profile_extensions: _, - } = EffectiveSandboxPermissions::new( - policy, - macos_seatbelt_profile_extensions, - additional_permissions.as_ref(), - ); + } = EffectiveSandboxPermissions::new(policy, additional_permissions.as_ref()); let effective_file_system_policy = effective_file_system_sandbox_policy( file_system_policy, additional_permissions.as_ref(), @@ -216,14 +200,13 @@ impl SandboxManager { SandboxType::None => (os_argv_to_strings(argv), None), #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => { - let mut args = create_seatbelt_command_args_for_policies_with_extensions( + let mut args = create_seatbelt_command_args_for_policies( os_argv_to_strings(argv), &effective_file_system_policy, effective_network_policy, sandbox_policy_cwd, enforce_managed_network, network, - effective_macos_seatbelt_profile_extensions.as_ref(), ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 67911bff6a..a32c9e586a 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -91,8 +91,6 @@ fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() enforce_managed_network: false, network: None, sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, @@ -134,7 +132,6 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { read: Some(vec![path]), write: Some(Vec::new()), }), - ..Default::default() }), }, policy: &SandboxPolicy::ExternalSandbox { @@ -146,8 +143,6 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { enforce_managed_network: false, network: None, sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, @@ -216,8 +211,6 @@ fn transform_additional_permissions_preserves_denied_entries() { enforce_managed_network: false, network: None, sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, diff --git a/codex-rs/sandboxing/src/policy_transforms.rs b/codex-rs/sandboxing/src/policy_transforms.rs index 045c7e3f90..25c79fcc9e 100644 --- a/codex-rs/sandboxing/src/policy_transforms.rs +++ b/codex-rs/sandboxing/src/policy_transforms.rs @@ -1,7 +1,4 @@ -use crate::macos_permissions::intersect_macos_seatbelt_profile_extensions; -use crate::macos_permissions::merge_macos_seatbelt_profile_extensions; use codex_protocol::models::FileSystemPermissions; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -20,28 +17,21 @@ use std::collections::HashSet; #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveSandboxPermissions { pub sandbox_policy: SandboxPolicy, - pub macos_seatbelt_profile_extensions: Option, } impl EffectiveSandboxPermissions { pub fn new( sandbox_policy: &SandboxPolicy, - macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>, additional_permissions: Option<&PermissionProfile>, ) -> Self { let Some(additional_permissions) = additional_permissions else { return Self { sandbox_policy: sandbox_policy.clone(), - macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions.cloned(), }; }; Self { sandbox_policy: effective_sandbox_policy(sandbox_policy, Some(additional_permissions)), - macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions( - macos_seatbelt_profile_extensions, - additional_permissions.macos.as_ref(), - ), } } } @@ -64,12 +54,9 @@ pub fn normalize_additional_permissions( FileSystemPermissions { read, write } }) .filter(|file_system| !file_system.is_empty()); - let macos = additional_permissions.macos; - Ok(PermissionProfile { network, file_system, - macos, }) } @@ -110,15 +97,10 @@ pub fn merge_permission_profiles( (None, Some(permissions)) => Some(permissions.clone()), (None, None) => None, }; - let macos = merge_macos_seatbelt_profile_extensions( - base.macos.as_ref(), - permissions.macos.as_ref(), - ); Some(PermissionProfile { network, file_system, - macos, }) .filter(|permissions| !permissions.is_empty()) } @@ -134,26 +116,10 @@ pub fn intersect_permission_profiles( .file_system .map(|requested_file_system| { let granted_file_system = granted.file_system.unwrap_or_default(); - let read = requested_file_system - .read - .map(|requested_read| { - let granted_read = granted_file_system.read.unwrap_or_default(); - requested_read - .into_iter() - .filter(|path| granted_read.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); - let write = requested_file_system - .write - .map(|requested_write| { - let granted_write = granted_file_system.write.unwrap_or_default(); - requested_write - .into_iter() - .filter(|path| granted_write.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); + let read = + intersect_permission_paths(requested_file_system.read, granted_file_system.read); + let write = + intersect_permission_paths(requested_file_system.write, granted_file_system.write); FileSystemPermissions { read, write } }) .filter(|file_system| !file_system.is_empty()); @@ -171,15 +137,32 @@ pub fn intersect_permission_profiles( _ => None, }; - let macos = intersect_macos_seatbelt_profile_extensions(requested.macos, granted.macos); - PermissionProfile { network, file_system, - macos, } } +fn intersect_permission_paths( + requested: Option>, + granted: Option>, +) -> Option> { + requested.and_then(|requested_paths| { + if requested_paths.is_empty() { + return granted.map(|_| Vec::new()); + } + + let granted_paths = granted.unwrap_or_default(); + Some( + requested_paths + .into_iter() + .filter(|path| granted_paths.contains(path)) + .collect::>(), + ) + .filter(|paths| !paths.is_empty()) + }) +} + fn normalize_permission_paths( paths: Vec, _permission_kind: &str, diff --git a/codex-rs/sandboxing/src/policy_transforms_tests.rs b/codex-rs/sandboxing/src/policy_transforms_tests.rs index a9c759a205..30dbac9242 100644 --- a/codex-rs/sandboxing/src/policy_transforms_tests.rs +++ b/codex-rs/sandboxing/src/policy_transforms_tests.rs @@ -1,21 +1,10 @@ -#[cfg(target_os = "macos")] -use super::EffectiveSandboxPermissions; use super::effective_file_system_sandbox_policy; -#[cfg(target_os = "macos")] use super::intersect_permission_profiles; use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; use super::sandbox_policy_with_additional_permissions; use super::should_require_platform_sandbox; use codex_protocol::models::FileSystemPermissions; -#[cfg(target_os = "macos")] -use codex_protocol::models::MacOsAutomationPermission; -#[cfg(target_os = "macos")] -use codex_protocol::models::MacOsContactsPermission; -#[cfg(target_os = "macos")] -use codex_protocol::models::MacOsPreferencesPermission; -#[cfg(target_os = "macos")] -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -110,7 +99,6 @@ fn normalize_additional_permissions_preserves_network() { read: Some(vec![path.clone()]), write: Some(vec![path.clone()]), }), - ..Default::default() }) .expect("permissions"); @@ -172,104 +160,73 @@ fn normalize_additional_permissions_drops_empty_nested_profiles() { read: None, write: None, }), - macos: None, }) .expect("permissions"); assert_eq!(permissions, PermissionProfile::default()); } -#[cfg(target_os = "macos")] #[test] -fn normalize_additional_permissions_preserves_default_macos_preferences_permission() { - let permissions = normalize_additional_permissions(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions::default()), - ..Default::default() - }) - .expect("permissions"); - - assert_eq!( - permissions, - PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions::default()), - ..Default::default() - } - ); -} - -#[cfg(target_os = "macos")] -#[test] -fn intersect_permission_profiles_preserves_default_macos_grants() { +fn intersect_permission_profiles_preserves_explicit_empty_requested_reads() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); let requested = PermissionProfile { file_system: Some(FileSystemPermissions { - read: Some(Vec::from(["/tmp/requested" - .try_into() - .expect("absolute path")])), - write: None, - }), - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, + read: Some(vec![]), + write: Some(vec![path]), }), ..Default::default() }; - let granted = PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(Vec::new()), - write: None, - }), - macos: Some(MacOsSeatbeltProfileExtensions::default()), - ..Default::default() - }; + let granted = requested.clone(); assert_eq!( - intersect_permission_profiles(requested, granted), - PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions::default()), - ..Default::default() - } + intersect_permission_profiles(requested.clone(), granted), + requested ); } -#[cfg(target_os = "macos")] #[test] -fn normalize_additional_permissions_preserves_macos_permissions() { - let permissions = normalize_additional_permissions(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, +fn intersect_permission_profiles_drops_ungranted_nonempty_path_requests() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![path]), + write: None, }), ..Default::default() - }) - .expect("permissions"); + }; assert_eq!( - permissions.macos, - Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }) + intersect_permission_profiles(requested, PermissionProfile::default()), + PermissionProfile::default() + ); +} + +#[test] +fn intersect_permission_profiles_drops_explicit_empty_reads_without_grant() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![path]), + }), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, PermissionProfile::default()), + PermissionProfile::default() ); } @@ -296,7 +253,6 @@ fn read_only_additional_permissions_can_enable_network_without_writes() { read: Some(vec![path.clone()]), write: Some(Vec::new()), }), - ..Default::default() }, ); @@ -312,70 +268,6 @@ fn read_only_additional_permissions_can_enable_network_without_writes() { ); } -#[cfg(target_os = "macos")] -#[test] -fn effective_permissions_merge_macos_extensions_with_additional_permissions() { - let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let effective_permissions = EffectiveSandboxPermissions::new( - &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![path.clone()], - }, - network_access: false, - }, - Some(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - Some(&PermissionProfile { - file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: Some(Vec::new()), - }), - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }), - ); - - assert_eq!( - effective_permissions.macos_seatbelt_profile_extensions, - Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }) - ); -} - #[test] fn external_sandbox_additional_permissions_can_enable_network() { let temp_dir = TempDir::new().expect("create temp dir"); @@ -395,7 +287,6 @@ fn external_sandbox_additional_permissions_can_enable_network() { read: Some(vec![path]), write: Some(Vec::new()), }), - ..Default::default() }, ); diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index e6ff71d0d3..5c11c4fc45 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -2,7 +2,6 @@ use codex_network_proxy::NetworkProxy; use codex_network_proxy::PROXY_URL_ENV_KEYS; use codex_network_proxy::has_proxy_url_env_vars; use codex_network_proxy::proxy_url_env_value; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; @@ -16,12 +15,18 @@ use std::path::PathBuf; use tracing::warn; use url::Url; -use crate::seatbelt_permissions::build_seatbelt_extensions; - const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str = include_str!("restricted_read_only_platform_defaults.sbpl"); +const MACOS_DEFAULT_PREFERENCES_POLICY: &str = r#"; allow readonly user preferences +(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) +(allow mach-lookup + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent")) +(allow user-preference-read) +"#; /// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` /// to defend against an attacker trying to inject a malicious version on the @@ -356,33 +361,30 @@ fn build_seatbelt_access_policy( } #[cfg_attr(not(test), allow(dead_code))] -fn create_seatbelt_command_args_with_extensions( +fn create_seatbelt_command_args( command: Vec, sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, enforce_managed_network: bool, network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, ) -> Vec { - create_seatbelt_command_args_for_policies_with_extensions( + create_seatbelt_command_args_for_policies( command, &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, enforce_managed_network, network, - extensions, ) } -pub fn create_seatbelt_command_args_for_policies_with_extensions( +pub fn create_seatbelt_command_args_for_policies( command: Vec, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, sandbox_policy_cwd: &Path, enforce_managed_network: bool, network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, ) -> Vec { let unreadable_roots = file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); @@ -470,17 +472,11 @@ pub fn create_seatbelt_command_args_for_policies_with_extensions( let proxy = proxy_policy_inputs(network); let network_policy = dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); - let seatbelt_extensions = extensions.map_or_else( - || { - // Backward-compatibility default when no extension profile is provided. - build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) - }, - build_seatbelt_extensions, - ); let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); let mut policy_sections = vec![ MACOS_SEATBELT_BASE_POLICY.to_string(), + MACOS_DEFAULT_PREFERENCES_POLICY.to_string(), file_read_policy, file_write_policy, network_policy, @@ -488,9 +484,6 @@ pub fn create_seatbelt_command_args_for_policies_with_extensions( if include_platform_defaults { policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); } - if !seatbelt_extensions.policy.is_empty() { - policy_sections.push(seatbelt_extensions.policy.clone()); - } let full_policy = policy_sections.join("\n"); @@ -499,7 +492,6 @@ pub fn create_seatbelt_command_args_for_policies_with_extensions( file_write_dir_params, macos_dir_params(), unix_socket_dir_params(&proxy), - seatbelt_extensions.dir_params, ] .concat(); diff --git a/codex-rs/sandboxing/src/seatbelt_permissions.rs b/codex-rs/sandboxing/src/seatbelt_permissions.rs deleted file mode 100644 index d53e7496ad..0000000000 --- a/codex-rs/sandboxing/src/seatbelt_permissions.rs +++ /dev/null @@ -1,190 +0,0 @@ -use std::collections::BTreeSet; -use std::path::PathBuf; - -use codex_protocol::models::MacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub(crate) struct SeatbeltExtensionPolicy { - pub(crate) policy: String, - pub(crate) dir_params: Vec<(String, PathBuf)>, -} - -fn normalized_extensions( - extensions: &MacOsSeatbeltProfileExtensions, -) -> MacOsSeatbeltProfileExtensions { - let mut normalized = extensions.clone(); - if let MacOsAutomationPermission::BundleIds(bundle_ids) = &extensions.macos_automation { - let bundle_ids = normalize_bundle_ids(bundle_ids); - normalized.macos_automation = if bundle_ids.is_empty() { - MacOsAutomationPermission::None - } else { - MacOsAutomationPermission::BundleIds(bundle_ids) - }; - } - - normalized -} - -pub(crate) fn build_seatbelt_extensions( - extensions: &MacOsSeatbeltProfileExtensions, -) -> SeatbeltExtensionPolicy { - let extensions = normalized_extensions(extensions); - let mut clauses = Vec::new(); - - match extensions.macos_preferences { - MacOsPreferencesPermission::None => {} - MacOsPreferencesPermission::ReadOnly => { - clauses.push( - "(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))" - .to_string(), - ); - clauses.push( - "(allow mach-lookup\n (global-name \"com.apple.cfprefsd.daemon\")\n (global-name \"com.apple.cfprefsd.agent\")\n (local-name \"com.apple.cfprefsd.agent\"))" - .to_string(), - ); - clauses.push("(allow user-preference-read)".to_string()); - } - MacOsPreferencesPermission::ReadWrite => { - clauses.push( - "(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))" - .to_string(), - ); - clauses.push( - "(allow mach-lookup\n (global-name \"com.apple.cfprefsd.daemon\")\n (global-name \"com.apple.cfprefsd.agent\")\n (local-name \"com.apple.cfprefsd.agent\"))" - .to_string(), - ); - clauses.push("(allow user-preference-read)".to_string()); - clauses.push("(allow user-preference-write)".to_string()); - clauses.push( - "(allow ipc-posix-shm-write-data (ipc-posix-name-prefix \"apple.cfprefs.\"))" - .to_string(), - ); - clauses.push( - "(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))" - .to_string(), - ); - } - } - - match extensions.macos_automation { - MacOsAutomationPermission::None => {} - MacOsAutomationPermission::All => { - clauses.push( - "(allow mach-lookup\n (global-name \"com.apple.coreservices.appleevents\"))" - .to_string(), - ); - clauses.push("(allow appleevent-send)".to_string()); - } - MacOsAutomationPermission::BundleIds(bundle_ids) => { - if !bundle_ids.is_empty() { - clauses.push( - "(allow mach-lookup\n (global-name \"com.apple.coreservices.appleevents\"))" - .to_string(), - ); - let destinations = bundle_ids - .iter() - .map(|bundle_id| format!(" (appleevent-destination \"{bundle_id}\")")) - .collect::>() - .join("\n"); - clauses.push(format!("(allow appleevent-send\n{destinations}\n)")); - } - } - } - - if extensions.macos_launch_services { - clauses.push( - "(allow mach-lookup\n (global-name \"com.apple.coreservices.launchservicesd\")\n (global-name \"com.apple.lsd.mapdb\")\n (global-name \"com.apple.coreservices.quarantine-resolver\")\n (global-name \"com.apple.lsd.modifydb\"))" - .to_string(), - ); - clauses.push("(allow lsopen)".to_string()); - } - - if extensions.macos_accessibility { - clauses.push("(allow mach-lookup (local-name \"com.apple.axserver\"))".to_string()); - } - - if extensions.macos_calendar { - clauses.push("(allow mach-lookup (global-name \"com.apple.CalendarAgent\"))".to_string()); - } - - if extensions.macos_reminders { - clauses.push( - "(allow mach-lookup\n (global-name \"com.apple.CalendarAgent\")\n (global-name \"com.apple.remindd\"))" - .to_string(), - ); - } - - let mut dir_params = Vec::new(); - match extensions.macos_contacts { - MacOsContactsPermission::None => {} - MacOsContactsPermission::ReadOnly => { - clauses.push( - "(allow file-read* file-test-existence\n (subpath \"/System/Library/Address Book Plug-Ins\")\n (subpath (param \"ADDRESSBOOK_DIR\")))" - .to_string(), - ); - clauses.push( - "(allow mach-lookup\n (global-name \"com.apple.tccd\")\n (global-name \"com.apple.tccd.system\")\n (global-name \"com.apple.contactsd.persistence\")\n (global-name \"com.apple.AddressBook.ContactsAccountsService\")\n (global-name \"com.apple.contacts.account-caching\")\n (global-name \"com.apple.accountsd.accountmanager\"))" - .to_string(), - ); - if let Some(addressbook_dir) = addressbook_dir() { - dir_params.push(("ADDRESSBOOK_DIR".to_string(), addressbook_dir)); - } - } - MacOsContactsPermission::ReadWrite => { - clauses.push( - "(allow file-read* file-write*\n (subpath \"/System/Library/Address Book Plug-Ins\")\n (subpath (param \"ADDRESSBOOK_DIR\"))\n (subpath \"/var/folders\")\n (subpath \"/private/var/folders\"))" - .to_string(), - ); - clauses.push( - "(allow mach-lookup\n (global-name \"com.apple.tccd\")\n (global-name \"com.apple.tccd.system\")\n (global-name \"com.apple.contactsd.persistence\")\n (global-name \"com.apple.AddressBook.ContactsAccountsService\")\n (global-name \"com.apple.contacts.account-caching\")\n (global-name \"com.apple.accountsd.accountmanager\")\n (global-name \"com.apple.securityd.xpc\"))" - .to_string(), - ); - if let Some(addressbook_dir) = addressbook_dir() { - dir_params.push(("ADDRESSBOOK_DIR".to_string(), addressbook_dir)); - } - } - } - - if clauses.is_empty() { - SeatbeltExtensionPolicy::default() - } else { - SeatbeltExtensionPolicy { - policy: format!( - "; macOS permission profile extensions\n{}\n", - clauses.join("\n") - ), - dir_params, - } - } -} - -fn addressbook_dir() -> Option { - Some(dirs::home_dir()?.join("Library/Application Support/AddressBook")) -} - -fn normalize_bundle_ids(bundle_ids: &[String]) -> Vec { - let mut unique = BTreeSet::new(); - for bundle_id in bundle_ids { - let candidate = bundle_id.trim(); - if is_valid_bundle_id(candidate) { - unique.insert(candidate.to_string()); - } - } - unique.into_iter().collect() -} - -fn is_valid_bundle_id(bundle_id: &str) -> bool { - if bundle_id.len() < 3 || !bundle_id.contains('.') { - return false; - } - bundle_id - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') -} - -#[cfg(test)] -#[path = "seatbelt_permissions_tests.rs"] -mod tests; diff --git a/codex-rs/sandboxing/src/seatbelt_permissions_tests.rs b/codex-rs/sandboxing/src/seatbelt_permissions_tests.rs deleted file mode 100644 index b52ccdfcb2..0000000000 --- a/codex-rs/sandboxing/src/seatbelt_permissions_tests.rs +++ /dev/null @@ -1,154 +0,0 @@ -use super::MacOsAutomationPermission; -use super::MacOsContactsPermission; -use super::MacOsPreferencesPermission; -use super::MacOsSeatbeltProfileExtensions; -use super::build_seatbelt_extensions; - -#[test] -fn preferences_read_only_emits_read_clauses_only() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - ..Default::default() - }); - assert!(policy.policy.contains("(allow user-preference-read)")); - assert!(!policy.policy.contains("(allow user-preference-write)")); -} - -#[test] -fn preferences_read_write_emits_write_clauses() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - ..Default::default() - }); - assert!(policy.policy.contains("(allow user-preference-read)")); - assert!(policy.policy.contains("(allow user-preference-write)")); - assert!( - policy.policy.contains( - "(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))" - ) - ); -} - -#[test] -fn automation_all_emits_unscoped_appleevents() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_automation: MacOsAutomationPermission::All, - ..Default::default() - }); - assert!(policy.policy.contains("(allow appleevent-send)")); - assert!(policy.policy.contains("com.apple.coreservices.appleevents")); -} - -#[test] -fn automation_bundle_ids_are_normalized_and_scoped() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - " com.apple.Notes ".to_string(), - "com.apple.Calendar".to_string(), - "bad bundle".to_string(), - "com.apple.Notes".to_string(), - ]), - ..Default::default() - }); - assert!( - policy - .policy - .contains("(appleevent-destination \"com.apple.Calendar\")") - ); - assert!( - policy - .policy - .contains("(appleevent-destination \"com.apple.Notes\")") - ); - assert!(!policy.policy.contains("bad bundle")); - assert!(policy.policy.contains("com.apple.coreservices.appleevents")); -} - -#[test] -fn launch_services_emit_launch_clauses() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_launch_services: true, - ..Default::default() - }); - assert!( - policy - .policy - .contains("com.apple.coreservices.launchservicesd") - ); - assert!(policy.policy.contains("com.apple.lsd.mapdb")); - assert!( - policy - .policy - .contains("com.apple.coreservices.quarantine-resolver") - ); - assert!(policy.policy.contains("com.apple.lsd.modifydb")); - assert!(policy.policy.contains("(allow lsopen)")); -} - -#[test] -fn accessibility_and_calendar_emit_mach_lookups() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_accessibility: true, - macos_calendar: true, - ..Default::default() - }); - assert!(policy.policy.contains("com.apple.axserver")); - assert!(policy.policy.contains("com.apple.CalendarAgent")); -} - -#[test] -fn reminders_emit_calendar_agent_and_remindd_lookups() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_reminders: true, - ..Default::default() - }); - assert!(policy.policy.contains("com.apple.CalendarAgent")); - assert!(policy.policy.contains("com.apple.remindd")); -} - -#[test] -fn contacts_read_only_emit_contacts_read_clauses() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_contacts: MacOsContactsPermission::ReadOnly, - ..Default::default() - }); - - assert!( - policy - .policy - .contains("(subpath \"/System/Library/Address Book Plug-Ins\")") - ); - assert!( - policy - .policy - .contains("(subpath (param \"ADDRESSBOOK_DIR\"))") - ); - assert!(policy.policy.contains("com.apple.contactsd.persistence")); - assert!(policy.policy.contains("com.apple.accountsd.accountmanager")); - assert!(!policy.policy.contains("com.apple.securityd.xpc")); - assert!( - policy - .dir_params - .iter() - .any(|(key, _)| key == "ADDRESSBOOK_DIR") - ); -} - -#[test] -fn contacts_read_write_emit_write_clauses() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions { - macos_contacts: MacOsContactsPermission::ReadWrite, - ..Default::default() - }); - - assert!(policy.policy.contains("(subpath \"/var/folders\")")); - assert!(policy.policy.contains("(subpath \"/private/var/folders\")")); - assert!(policy.policy.contains("com.apple.securityd.xpc")); -} - -#[test] -fn default_extensions_emit_preferences_read_only_policy() { - let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()); - assert!(policy.policy.contains("(allow user-preference-read)")); - assert!(!policy.policy.contains("(allow user-preference-write)")); -} diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index e508a028c2..cc6fce56a4 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -2,17 +2,13 @@ use super::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; use super::UnixDomainSocketPolicy; -use super::create_seatbelt_command_args_for_policies_with_extensions; -use super::create_seatbelt_command_args_with_extensions; +use super::create_seatbelt_command_args; +use super::create_seatbelt_command_args_for_policies; use super::dynamic_network_policy; use super::macos_dir_params; use super::normalize_path_for_sandbox; use super::unix_socket_dir_params; use super::unix_socket_policy; -use codex_protocol::models::MacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -115,14 +111,13 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() }, ]); - let args = create_seatbelt_command_args_for_policies_with_extensions( + let args = create_seatbelt_command_args_for_policies( vec!["/bin/true".to_string()], &file_system_policy, NetworkSandboxPolicy::Restricted, Path::new("/"), false, None, - None, ); let policy = seatbelt_policy_arg(&args); @@ -181,14 +176,13 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() { }, ]); - let args = create_seatbelt_command_args_for_policies_with_extensions( + let args = create_seatbelt_command_args_for_policies( vec!["/bin/true".to_string()], &file_system_policy, NetworkSandboxPolicy::Restricted, Path::new("/"), false, None, - None, ); let policy = seatbelt_policy_arg(&args); @@ -217,96 +211,15 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() { ); } -#[test] -fn seatbelt_args_include_macos_permission_extensions() { - let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args_with_extensions( - vec!["echo".to_string(), "ok".to_string()], - &SandboxPolicy::new_read_only_policy(), - cwd.as_path(), - false, - None, - Some(&MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ); - let policy = &args[1]; - - assert!(policy.contains("(allow user-preference-write)")); - assert!(policy.contains("(appleevent-destination \"com.apple.Notes\")")); - assert!(policy.contains("com.apple.axserver")); - assert!(policy.contains("com.apple.CalendarAgent")); -} - -#[test] -fn bundle_id_automation_keeps_lsopen_denied() { - let tmp = TempDir::new().expect("tempdir"); - let cwd = tmp.path().join("cwd"); - fs::create_dir_all(&cwd).expect("create cwd"); - - let args = create_seatbelt_command_args_with_extensions( - vec![ - "/usr/bin/python3".to_string(), - "-c".to_string(), - r#"import ctypes -import os -import sys -lib = ctypes.CDLL("/usr/lib/libsandbox.1.dylib") -lib.sandbox_check.restype = ctypes.c_int -allowed = lib.sandbox_check(os.getpid(), b"lsopen", 0) == 0 -sys.exit(0 if allowed else 13) -"# - .to_string(), - ], - &SandboxPolicy::new_read_only_policy(), - cwd.as_path(), - false, - None, - Some(&MacOsSeatbeltProfileExtensions { - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - ..Default::default() - }), - ); - - let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) - .args(&args) - .current_dir(&cwd) - .output() - .expect("execute seatbelt command"); - - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted") { - return; - } - - assert_eq!( - Some(13), - output.status.code(), - "lsopen should remain denied even with bundle-scoped automation\nstdout: {}\nstderr: {stderr}", - String::from_utf8_lossy(&output.stdout), - ); -} - #[test] fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() { let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args_with_extensions( + let args = create_seatbelt_command_args( vec!["echo".to_string(), "ok".to_string()], &SandboxPolicy::new_read_only_policy(), cwd.as_path(), /*enforce_managed_network*/ false, None, - /*extensions*/ None, ); let policy = &args[1]; assert!(policy.contains("(allow user-preference-read)")); @@ -319,7 +232,7 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { let cwd = tmp.path().join("workspace"); fs::create_dir_all(cwd.join("docs")).expect("create docs"); let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); - let args = create_seatbelt_command_args_with_extensions( + let args = create_seatbelt_command_args( vec!["/bin/true".to_string()], &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], @@ -334,7 +247,6 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { cwd.as_path(), /*enforce_managed_network*/ false, None, - /*extensions*/ None, ); assert!( @@ -351,25 +263,6 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { ); } -#[test] -fn seatbelt_args_default_extension_profile_keeps_preferences_read_access() { - let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args_with_extensions( - vec!["echo".to_string(), "ok".to_string()], - &SandboxPolicy::new_read_only_policy(), - cwd.as_path(), - false, - None, - Some(&MacOsSeatbeltProfileExtensions::default()), - ); - let policy = &args[1]; - assert!(!policy.contains("appleevent-send")); - assert!(!policy.contains("com.apple.axserver")); - assert!(!policy.contains("com.apple.CalendarAgent")); - assert!(policy.contains("(allow user-preference-read)")); - assert!(!policy.contains("user-preference-write")); -} - #[test] fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() { let policy = dynamic_network_policy( @@ -664,13 +557,12 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args_with_extensions( + let args = create_seatbelt_command_args( shell_command.clone(), &policy, &cwd, /*enforce_managed_network*/ false, None, - /*extensions*/ None, ); let policy_text = seatbelt_policy_arg(&args); @@ -779,13 +671,12 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_hooks_file_args = create_seatbelt_command_args_with_extensions( + let write_hooks_file_args = create_seatbelt_command_args( shell_command_git, &policy, &cwd, /*enforce_managed_network*/ false, None, - /*extensions*/ None, ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) @@ -816,13 +707,12 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_allowed_file_args = create_seatbelt_command_args_with_extensions( + let write_allowed_file_args = create_seatbelt_command_args( shell_command_allowed, &policy, &cwd, /*enforce_managed_network*/ false, None, - /*extensions*/ None, ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) @@ -882,13 +772,12 @@ fn create_seatbelt_args_block_first_time_dot_codex_creation_with_exact_and_desce .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args_with_extensions( + let args = create_seatbelt_command_args( shell_command, &policy, repo_root.as_path(), /*enforce_managed_network*/ false, /*network*/ None, - /*extensions*/ None, ); let policy_text = seatbelt_policy_arg(&args); @@ -938,13 +827,12 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args_with_extensions( + let args = create_seatbelt_command_args( shell_command, &policy, &cwd, /*enforce_managed_network*/ false, None, - /*extensions*/ None, ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) @@ -975,13 +863,12 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let gitdir_args = create_seatbelt_command_args_with_extensions( + let gitdir_args = create_seatbelt_command_args( shell_command_gitdir, &policy, &cwd, /*enforce_managed_network*/ false, None, - /*extensions*/ None, ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) @@ -1039,13 +926,12 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args_with_extensions( + let args = create_seatbelt_command_args( shell_command.clone(), &policy, vulnerable_root.as_path(), /*enforce_managed_network*/ false, None, - /*extensions*/ None, ); let tmpdir_env_var = std::env::var("TMPDIR") @@ -1067,19 +953,19 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { // - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. let expected_policy = format!( r#"{MACOS_SEATBELT_BASE_POLICY} -; allow read-only file operations -(allow file-read*) -(allow file-write* -(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} -) - -; macOS permission profile extensions +; allow readonly user preferences (allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) (allow mach-lookup (global-name "com.apple.cfprefsd.daemon") (global-name "com.apple.cfprefsd.agent") (local-name "com.apple.cfprefsd.agent")) (allow user-preference-read) + +; allow read-only file operations +(allow file-read*) +(allow file-write* +(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} +) "#, ); diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 1b403c251f..0d6ef77c1f 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -19,9 +19,6 @@ use crate::render::renderable::Renderable; use codex_features::Features; use codex_protocol::ThreadId; use codex_protocol::mcp::RequestId; -use codex_protocol::models::MacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ElicitationAction; use codex_protocol::protocol::FileChange; @@ -777,48 +774,6 @@ pub(crate) fn format_additional_permissions_rule( parts.push(format!("write {writes}")); } } - if let Some(macos) = additional_permissions.macos.as_ref() { - if !matches!( - macos.macos_preferences, - MacOsPreferencesPermission::ReadOnly - ) { - let value = match macos.macos_preferences { - MacOsPreferencesPermission::ReadOnly => "readonly", - MacOsPreferencesPermission::ReadWrite => "readwrite", - MacOsPreferencesPermission::None => "none", - }; - parts.push(format!("macOS preferences {value}")); - } - match &macos.macos_automation { - MacOsAutomationPermission::All => { - parts.push("macOS automation all".to_string()); - } - MacOsAutomationPermission::BundleIds(bundle_ids) => { - if !bundle_ids.is_empty() { - parts.push(format!("macOS automation {}", bundle_ids.join(", "))); - } - } - MacOsAutomationPermission::None => {} - } - if macos.macos_accessibility { - parts.push("macOS accessibility".to_string()); - } - if macos.macos_calendar { - parts.push("macOS calendar".to_string()); - } - if macos.macos_reminders { - parts.push("macOS reminders".to_string()); - } - if !matches!(macos.macos_contacts, MacOsContactsPermission::None) { - let value = match macos.macos_contacts { - MacOsContactsPermission::None => "none", - MacOsContactsPermission::ReadOnly => "readonly", - MacOsContactsPermission::ReadWrite => "readwrite", - }; - parts.push(format!("macOS contacts {value}")); - } - } - if parts.is_empty() { None } else { @@ -906,9 +861,6 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_protocol::models::FileSystemPermissions; - use codex_protocol::models::MacOsAutomationPermission; - use codex_protocol::models::MacOsPreferencesPermission; - use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::NetworkPermissions; use codex_protocol::protocol::ExecPolicyAmendment; use codex_protocol::protocol::NetworkApprovalProtocol; @@ -1334,7 +1286,6 @@ mod tests { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), }), - ..Default::default() }), }; @@ -1382,7 +1333,6 @@ mod tests { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), }), - ..Default::default() }), }; @@ -1404,42 +1354,6 @@ mod tests { ); } - #[test] - fn additional_permissions_macos_prompt_snapshot() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let exec_request = ApprovalRequest::Exec { - thread_id: ThreadId::new(), - thread_label: None, - id: "test".into(), - command: vec!["osascript".into(), "-e".into(), "tell application".into()], - reason: Some("need macOS automation".into()), - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], - network_approval_context: None, - additional_permissions: Some(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - "com.apple.Notes".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }), - }; - - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); - assert_snapshot!( - "approval_overlay_additional_permissions_macos_prompt", - render_overlay_lines(&view, 120) - ); - } - #[test] fn network_exec_prompt_title_includes_host() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap deleted file mode 100644 index d9d8717fe9..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/bottom_pane/approval_overlay.rs -expression: "render_overlay_lines(&view, 120)" ---- - - Would you like to run the following command? - - Reason: need macOS automation - - Permission rule: macOS preferences readwrite; macOS automation com.apple.Calendar, com.apple.Notes; macOS - accessibility; macOS calendar; macOS reminders - - $ osascript -e 'tell application' - -› 1. Yes, proceed (y) - 2. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index a86a8c9388..c36f793b75 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -7568,7 +7568,6 @@ guardian_approval = true enabled: Some(true), }), file_system: None, - macos: None, }); params.proposed_network_policy_amendments = Some(vec![AppServerNetworkPolicyAmendment { host: "example.com".to_string(), @@ -7601,7 +7600,6 @@ guardian_approval = true enabled: Some(true), }), file_system: None, - macos: None, }) ); assert_eq!( diff --git a/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs b/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs index f5d1cee621..84f6da6cb7 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs @@ -19,9 +19,6 @@ use crate::render::renderable::Renderable; use codex_features::Features; use codex_protocol::ThreadId; use codex_protocol::mcp::RequestId; -use codex_protocol::models::MacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ElicitationAction; use codex_protocol::protocol::FileChange; @@ -763,48 +760,6 @@ pub(crate) fn format_additional_permissions_rule( parts.push(format!("write {writes}")); } } - if let Some(macos) = additional_permissions.macos.as_ref() { - if !matches!( - macos.macos_preferences, - MacOsPreferencesPermission::ReadOnly - ) { - let value = match macos.macos_preferences { - MacOsPreferencesPermission::ReadOnly => "readonly", - MacOsPreferencesPermission::ReadWrite => "readwrite", - MacOsPreferencesPermission::None => "none", - }; - parts.push(format!("macOS preferences {value}")); - } - match &macos.macos_automation { - MacOsAutomationPermission::All => { - parts.push("macOS automation all".to_string()); - } - MacOsAutomationPermission::BundleIds(bundle_ids) => { - if !bundle_ids.is_empty() { - parts.push(format!("macOS automation {}", bundle_ids.join(", "))); - } - } - MacOsAutomationPermission::None => {} - } - if macos.macos_accessibility { - parts.push("macOS accessibility".to_string()); - } - if macos.macos_calendar { - parts.push("macOS calendar".to_string()); - } - if macos.macos_reminders { - parts.push("macOS reminders".to_string()); - } - if !matches!(macos.macos_contacts, MacOsContactsPermission::None) { - let value = match macos.macos_contacts { - MacOsContactsPermission::None => "none", - MacOsContactsPermission::ReadOnly => "readonly", - MacOsContactsPermission::ReadWrite => "readwrite", - }; - parts.push(format!("macOS contacts {value}")); - } - } - if parts.is_empty() { None } else { @@ -892,9 +847,6 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_protocol::models::FileSystemPermissions; - use codex_protocol::models::MacOsAutomationPermission; - use codex_protocol::models::MacOsPreferencesPermission; - use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::NetworkPermissions; use codex_protocol::protocol::ExecPolicyAmendment; use codex_protocol::protocol::NetworkApprovalProtocol; @@ -1320,7 +1272,6 @@ mod tests { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), }), - ..Default::default() }), }; @@ -1368,7 +1319,6 @@ mod tests { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), }), - ..Default::default() }), }; @@ -1390,42 +1340,6 @@ mod tests { ); } - #[test] - fn additional_permissions_macos_prompt_snapshot() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let exec_request = ApprovalRequest::Exec { - thread_id: ThreadId::new(), - thread_label: None, - id: "test".into(), - command: vec!["osascript".into(), "-e".into(), "tell application".into()], - reason: Some("need macOS automation".into()), - available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], - network_approval_context: None, - additional_permissions: Some(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Calendar".to_string(), - "com.apple.Notes".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }), - }; - - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); - assert_snapshot!( - "approval_overlay_additional_permissions_macos_prompt", - render_overlay_lines(&view, 120) - ); - } - #[test] fn network_exec_prompt_title_includes_host() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap deleted file mode 100644 index d9d8717fe9..0000000000 --- a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/bottom_pane/approval_overlay.rs -expression: "render_overlay_lines(&view, 120)" ---- - - Would you like to run the following command? - - Reason: need macOS automation - - Permission rule: macOS preferences readwrite; macOS automation com.apple.Calendar, com.apple.Notes; macOS - accessibility; macOS calendar; macOS reminders - - $ osascript -e 'tell application' - -› 1. Yes, proceed (y) - 2. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap b/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap deleted file mode 100644 index 7731a880be..0000000000 --- a/codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_macos_prompt.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui_app_server/src/bottom_pane/approval_overlay.rs -expression: "render_overlay_lines(&view, 120)" ---- - - Would you like to run the following command? - - Reason: need macOS automation - - Permission rule: macOS preferences readwrite; macOS automation com.apple.Calendar, com.apple.Notes; macOS - accessibility; macOS calendar; macOS reminders - - $ osascript -e 'tell application' - -› 1. Yes, proceed (y) - 2. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel From 81fa04783a551d7e2fc5cfdfc91921aaec3e64bb Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Thu, 26 Mar 2026 17:27:38 -0700 Subject: [PATCH 003/185] feat(windows-sandbox): add network proxy support (#12220) ## Summary This PR makes Windows sandbox proxying enforceable by routing proxy-only runs through the existing `offline` sandbox user and reserving direct network access for the existing `online` sandbox user. In brief: - if a Windows sandbox run should be proxy-enforced, we run it as the `offline` user - the `offline` user gets firewall rules that block direct outbound traffic and only permit the configured localhost proxy path - if a Windows sandbox run should have true direct network access, we run it as the `online` user - no new sandbox identity is introduced This brings Windows in line with the intended model: proxy use is not just env-based, it is backed by OS-level egress controls. Windows already has two sandbox identities: - `offline`: intended to have no direct network egress - `online`: intended to have full network access This PR makes proxy-enforced runs use that model directly. ### Proxy-enforced runs When proxy enforcement is active: - the run is assigned to the `offline` identity - setup extracts the loopback proxy ports from the sandbox env - Windows setup programs firewall rules for the `offline` user that: - block all non-loopback outbound traffic - block loopback UDP - block loopback TCP except for the configured proxy ports - optionally allow broader localhost access when `allow_local_binding=1` So the sandboxed process can only talk to the local proxy. It cannot open direct outbound sockets or do local UDP-based DNS on its own.The proxy then performs the real outbound network access outside that restricted sandbox identity. ### Direct-network runs When proxy enforcement is not active and full network access is allowed: - the run is assigned to the `online` identity - no proxy-only firewall restrictions are applied - the process gets normal direct network access ### Unelevated vs elevated The restricted-token / unelevated path cannot enforce per-identity firewall policy by itself. So for Windows proxy-enforced runs, we transparently use the logon-user sandbox path under the hood, even if the caller started from the unelevated mode. That keeps enforcement real instead of best-effort. --------- Co-authored-by: Codex --- codex-rs/cli/src/debug_sandbox.rs | 19 +- codex-rs/core/src/exec.rs | 25 +- codex-rs/core/src/windows_sandbox.rs | 17 +- codex-rs/network-proxy/src/proxy.rs | 225 ++++++++-- .../windows-sandbox-rs/sandbox_smoketests.py | 133 +++++- .../windows-sandbox-rs/src/elevated_impl.rs | 59 ++- codex-rs/windows-sandbox-rs/src/firewall.rs | 254 +++++++++-- codex-rs/windows-sandbox-rs/src/identity.rs | 70 ++- codex-rs/windows-sandbox-rs/src/lib.rs | 6 + .../windows-sandbox-rs/src/sandbox_users.rs | 10 + .../windows-sandbox-rs/src/setup_main_win.rs | 21 + .../src/setup_orchestrator.rs | 409 +++++++++++++++--- 12 files changed, 1032 insertions(+), 216 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index e5cccfc53a..f64c45c73f 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -164,14 +164,17 @@ async fn run_command_under_sandbox( let res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( - policy_str.as_str(), - &sandbox_cwd, - base_dir.as_path(), - command_vec, - &cwd_clone, - env_map, - /*timeout_ms*/ None, - config.permissions.windows_sandbox_private_desktop, + codex_windows_sandbox::ElevatedSandboxCaptureRequest { + policy_json_or_preset: policy_str.as_str(), + sandbox_policy_cwd: &sandbox_cwd, + codex_home: base_dir.as_path(), + command: command_vec, + cwd: &cwd_clone, + env_map, + timeout_ms: None, + use_private_desktop: config.permissions.windows_sandbox_private_desktop, + proxy_enforced: false, + }, ) } else { run_windows_sandbox_capture( diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 11d8600ba1..fde8782f08 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -478,7 +478,11 @@ async fn exec_windows_sandbox( })?; let command_path = command.first().cloned(); let sandbox_level = windows_sandbox_level; - let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated); + let proxy_enforced = network.is_some(); + // Windows firewall enforcement is tied to the logon-user sandbox identities, so + // proxy-enforced sessions must use that backend even when the configured mode is + // the default restricted-token sandbox. + let use_elevated = proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated); let additional_deny_write_paths = windows_restricted_token_filesystem_overlay .map(|overlay| { overlay @@ -491,14 +495,17 @@ async fn exec_windows_sandbox( let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( - policy_str.as_str(), - &sandbox_cwd, - codex_home.as_ref(), - command, - &cwd, - env, - timeout_ms, - windows_sandbox_private_desktop, + codex_windows_sandbox::ElevatedSandboxCaptureRequest { + policy_json_or_preset: policy_str.as_str(), + sandbox_policy_cwd: &sandbox_cwd, + codex_home: codex_home.as_ref(), + command, + cwd: &cwd, + env_map: env, + timeout_ms, + use_private_desktop: windows_sandbox_private_desktop, + proxy_enforced, + }, ) } else { run_windows_sandbox_capture_with_extra_deny_write_paths( diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 1fdf3ee338..ee3a040a69 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -180,13 +180,15 @@ pub fn run_elevated_setup( codex_home: &Path, ) -> anyhow::Result<()> { codex_windows_sandbox::run_elevated_setup( - policy, - policy_cwd, - command_cwd, - env_map, - codex_home, - /*read_roots_override*/ None, - /*write_roots_override*/ None, + codex_windows_sandbox::SandboxSetupRequest { + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + proxy_enforced: false, + }, + codex_windows_sandbox::SetupRootOverrides::default(), ) } @@ -234,6 +236,7 @@ pub fn run_setup_refresh_with_extra_read_roots( env_map, codex_home, extra_read_roots, + /*proxy_enforced*/ false, ) } diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 8f596f6842..3eecef3ee7 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -51,6 +51,43 @@ impl ReservedListeners { } } +struct ReservedListenerSet { + http_listener: StdTcpListener, + socks_listener: Option, +} + +impl ReservedListenerSet { + fn new(http_listener: StdTcpListener, socks_listener: Option) -> Self { + Self { + http_listener, + socks_listener, + } + } + + fn http_addr(&self) -> Result { + self.http_listener + .local_addr() + .context("failed to read reserved HTTP proxy address") + } + + fn socks_addr(&self, default_addr: SocketAddr) -> Result { + self.socks_listener + .as_ref() + .map_or(Ok(default_addr), |listener| { + listener + .local_addr() + .context("failed to read reserved SOCKS5 proxy address") + }) + } + + fn into_reserved_listeners(self) -> Arc { + Arc::new(ReservedListeners::new( + self.http_listener, + self.socks_listener, + )) + } +} + #[derive(Clone)] pub struct NetworkProxyBuilder { state: Option>, @@ -134,38 +171,41 @@ impl NetworkProxyBuilder { .set_blocked_request_observer(self.blocked_request_observer.clone()) .await; let current_cfg = state.current_cfg().await?; - let (requested_http_addr, requested_socks_addr, reserved_listeners) = - if self.managed_by_codex { - let runtime = config::resolve_runtime(¤t_cfg)?; - let (http_listener, socks_listener) = - reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5) - .context("reserve managed loopback proxy listeners")?; - let http_addr = http_listener - .local_addr() - .context("failed to read reserved HTTP proxy address")?; - let socks_addr = if let Some(socks_listener) = socks_listener.as_ref() { - socks_listener - .local_addr() - .context("failed to read reserved SOCKS5 proxy address")? - } else { - runtime.socks_addr - }; - ( - http_addr, - socks_addr, - Some(Arc::new(ReservedListeners::new( - http_listener, - socks_listener, - ))), - ) - } else { - let runtime = config::resolve_runtime(¤t_cfg)?; - ( - self.http_addr.unwrap_or(runtime.http_addr), - self.socks_addr.unwrap_or(runtime.socks_addr), - None, - ) - }; + let (requested_http_addr, requested_socks_addr, reserved_listeners) = if self + .managed_by_codex + { + let runtime = config::resolve_runtime(¤t_cfg)?; + #[cfg(target_os = "windows")] + let (managed_http_addr, managed_socks_addr) = config::clamp_bind_addrs( + runtime.http_addr, + runtime.socks_addr, + ¤t_cfg.network, + ); + #[cfg(target_os = "windows")] + let reserved = reserve_windows_managed_listeners( + managed_http_addr, + managed_socks_addr, + current_cfg.network.enable_socks5, + ) + .context("reserve managed loopback proxy listeners")?; + #[cfg(not(target_os = "windows"))] + let reserved = reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5) + .context("reserve managed loopback proxy listeners")?; + let http_addr = reserved.http_addr()?; + let socks_addr = reserved.socks_addr(runtime.socks_addr)?; + ( + http_addr, + socks_addr, + Some(reserved.into_reserved_listeners()), + ) + } else { + let runtime = config::resolve_runtime(¤t_cfg)?; + ( + self.http_addr.unwrap_or(runtime.http_addr), + self.socks_addr.unwrap_or(runtime.socks_addr), + None, + ) + }; // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only. let (http_addr, socks_addr) = config::clamp_bind_addrs( @@ -192,7 +232,7 @@ impl NetworkProxyBuilder { fn reserve_loopback_ephemeral_listeners( reserve_socks_listener: bool, -) -> Result<(StdTcpListener, Option)> { +) -> Result { let http_listener = reserve_loopback_ephemeral_listener().context("reserve HTTP proxy listener")?; let socks_listener = if reserve_socks_listener { @@ -200,7 +240,53 @@ fn reserve_loopback_ephemeral_listeners( } else { None }; - Ok((http_listener, socks_listener)) + Ok(ReservedListenerSet::new(http_listener, socks_listener)) +} + +#[cfg(target_os = "windows")] +fn reserve_windows_managed_listeners( + http_addr: SocketAddr, + socks_addr: SocketAddr, + reserve_socks_listener: bool, +) -> Result { + let http_addr = windows_managed_loopback_addr(http_addr); + let socks_addr = windows_managed_loopback_addr(socks_addr); + + match try_reserve_windows_managed_listeners(http_addr, socks_addr, reserve_socks_listener) { + Ok(listeners) => Ok(listeners), + Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => { + warn!("managed Windows proxy ports are busy; falling back to ephemeral loopback ports"); + reserve_loopback_ephemeral_listeners(reserve_socks_listener) + .context("reserve fallback loopback proxy listeners") + } + Err(err) => Err(err).context("reserve Windows managed proxy listeners"), + } +} + +#[cfg(target_os = "windows")] +fn try_reserve_windows_managed_listeners( + http_addr: SocketAddr, + socks_addr: SocketAddr, + reserve_socks_listener: bool, +) -> std::io::Result { + let http_listener = StdTcpListener::bind(http_addr)?; + let socks_listener = if reserve_socks_listener { + Some(StdTcpListener::bind(socks_addr)?) + } else { + None + }; + Ok(ReservedListenerSet::new(http_listener, socks_listener)) +} + +#[cfg(target_os = "windows")] +fn windows_managed_loopback_addr(addr: SocketAddr) -> SocketAddr { + if !addr.ip().is_loopback() { + warn!( + "managed Windows proxies must bind to loopback; clamping {addr} to 127.0.0.1:{}", + addr.port() + ); + } + SocketAddr::from(([127, 0, 0, 1], addr.port())) } fn reserve_loopback_ephemeral_listener() -> Result { @@ -570,10 +656,12 @@ mod tests { use std::net::Ipv4Addr; #[tokio::test] - async fn managed_proxy_builder_uses_loopback_ephemeral_ports() { - let state = Arc::new(network_proxy_state_for_policy( - NetworkProxySettings::default(), - )); + async fn managed_proxy_builder_uses_loopback_ports() { + let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { + proxy_url: "http://127.0.0.1:43128".to_string(), + socks_url: "http://127.0.0.1:48081".to_string(), + ..NetworkProxySettings::default() + })); let proxy = match NetworkProxy::builder().state(state).build().await { Ok(proxy) => proxy, Err(err) => { @@ -589,8 +677,22 @@ mod tests { assert!(proxy.http_addr.ip().is_loopback()); assert!(proxy.socks_addr.ip().is_loopback()); - assert_ne!(proxy.http_addr.port(), 0); - assert_ne!(proxy.socks_addr.port(), 0); + #[cfg(target_os = "windows")] + { + assert_eq!( + proxy.http_addr, + "127.0.0.1:43128".parse::().unwrap() + ); + assert_eq!( + proxy.socks_addr, + "127.0.0.1:48081".parse::().unwrap() + ); + } + #[cfg(not(target_os = "windows"))] + { + assert_ne!(proxy.http_addr.port(), 0); + assert_ne!(proxy.socks_addr.port(), 0); + } } #[tokio::test] @@ -622,6 +724,7 @@ mod tests { async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() { let settings = NetworkProxySettings { enable_socks5: false, + proxy_url: "http://127.0.0.1:43128".to_string(), socks_url: "http://127.0.0.1:43129".to_string(), ..NetworkProxySettings::default() }; @@ -640,6 +743,7 @@ mod tests { }; assert!(proxy.http_addr.ip().is_loopback()); + assert_ne!(proxy.http_addr.port(), 0); assert_eq!( proxy.socks_addr, "127.0.0.1:43129".parse::().unwrap() @@ -654,6 +758,47 @@ mod tests { ); } + #[cfg(target_os = "windows")] + #[test] + fn windows_managed_loopback_addr_clamps_non_loopback_inputs() { + assert_eq!( + windows_managed_loopback_addr("0.0.0.0:3128".parse::().unwrap()), + "127.0.0.1:3128".parse::().unwrap() + ); + assert_eq!( + windows_managed_loopback_addr("[::]:8081".parse::().unwrap()), + "127.0.0.1:8081".parse::().unwrap() + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() { + let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let busy_port = occupied.local_addr().unwrap().port(); + + let reserved = reserve_windows_managed_listeners( + SocketAddr::from(([127, 0, 0, 1], busy_port)), + SocketAddr::from(([127, 0, 0, 1], 48081)), + false, + ) + .unwrap(); + + assert!(reserved.socks_listener.is_none()); + assert!( + reserved + .http_listener + .local_addr() + .unwrap() + .ip() + .is_loopback() + ); + assert_ne!( + reserved.http_listener.local_addr().unwrap().port(), + busy_port + ); + } + #[test] fn proxy_url_env_value_resolves_lowercase_aliases() { let mut env = HashMap::new(); diff --git a/codex-rs/windows-sandbox-rs/sandbox_smoketests.py b/codex-rs/windows-sandbox-rs/sandbox_smoketests.py index 0fc049d4ed..f629a4c6d9 100644 --- a/codex-rs/windows-sandbox-rs/sandbox_smoketests.py +++ b/codex-rs/windows-sandbox-rs/sandbox_smoketests.py @@ -6,8 +6,13 @@ import os import sys import shutil import subprocess +import contextlib +import http.client +import http.server +import threading from pathlib import Path from typing import List, Optional, Tuple +from urllib.parse import urlsplit def _resolve_codex_cmd() -> List[str]: """Resolve the Codex CLI to invoke `codex sandbox windows`. @@ -135,6 +140,68 @@ def make_symlink(link: Path, target: Path) -> bool: cp = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) return cp.returncode == 0 and link.exists() +class _QuietHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass + +class _TargetHandler(_QuietHandler): + def do_GET(self): + body = b"proxy-ok" + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + +class _ProxyHandler(_QuietHandler): + def do_GET(self): + parsed = urlsplit(self.path) + if not parsed.scheme or not parsed.hostname: + self.send_error(400, "absolute URL required") + return + if parsed.hostname not in ("127.0.0.1", "localhost"): + self.send_error(403, "only loopback hosts are allowed in smoke proxy") + return + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + conn = None + try: + conn = http.client.HTTPConnection(parsed.hostname, parsed.port or 80, timeout=2) + conn.request("GET", path) + upstream = conn.getresponse() + body = upstream.read() + except Exception as err: + self.send_error(502, f"proxy upstream error: {err}") + return + finally: + if conn is not None: + with contextlib.suppress(Exception): + conn.close() + self.send_response(upstream.status, upstream.reason) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + +@contextlib.contextmanager +def start_loopback_proxy_fixture(): + target = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _TargetHandler) + proxy = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _ProxyHandler) + target_port = target.server_address[1] + proxy_port = proxy.server_address[1] + target_thread = threading.Thread(target=target.serve_forever, daemon=True) + proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True) + target_thread.start() + proxy_thread.start() + try: + yield target_port, proxy_port + finally: + proxy.shutdown() + target.shutdown() + proxy.server_close() + target.server_close() + def summarize(results: List[CaseResult]) -> int: ok = sum(1 for r in results if r.ok) total = len(results) @@ -278,7 +345,65 @@ def main() -> int: "try { iwr http://neverssl.com -TimeoutSec 2 } catch { exit 1 }"], WS_ROOT) add("WS: iwr network blocked", rc != 0, f"rc={rc}") - # 17. RO: deny TEMP writes via PowerShell + # 17. WS: direct loopback blocked, proxy loopback allowed via env proxy + if have("curl"): + with start_loopback_proxy_fixture() as (target_port, proxy_port): + proxy_home = WS_ROOT / ".codex_proxy_smoke" + remove_if_exists(proxy_home) + proxy_home.mkdir(parents=True, exist_ok=True) + proxy_url = f"http://127.0.0.1:{proxy_port}" + proxy_env = { + "CODEX_HOME": str(proxy_home), + "HTTP_PROXY": proxy_url, + "http_proxy": proxy_url, + "ALL_PROXY": proxy_url, + "all_proxy": proxy_url, + "NO_PROXY": "", + "no_proxy": "", + } + proxied_cmd = [ + "curl", + "--noproxy", + "", + "--connect-timeout", + "2", + "--max-time", + "4", + f"http://127.0.0.1:{target_port}/proxied", + ] + rc_proxy, out_proxy, err_proxy = run_sbx( + "workspace-write", + proxied_cmd, + WS_ROOT, + env_extra=proxy_env, + ) + add( + "WS: loopback proxy allowed", + rc_proxy == 0 and "proxy-ok" in out_proxy, + f"rc={rc_proxy}, out={out_proxy}, err={err_proxy}", + ) + + direct_cmd = [ + "curl", + "--noproxy", + "*", + "--connect-timeout", + "1", + "--max-time", + "2", + f"http://127.0.0.1:{target_port}/direct", + ] + rc_direct, _out_direct, err_direct = run_sbx( + "workspace-write", + direct_cmd, + WS_ROOT, + env_extra={"CODEX_HOME": str(proxy_home)}, + ) + add("WS: direct loopback blocked", rc_direct != 0, f"rc={rc_direct}, err={err_direct}") + else: + add("WS: direct/proxy loopback tests (curl missing)", True, "curl not installed") + + # 18. RO: deny TEMP writes via PowerShell rc, out, err = run_sbx("read-only", ["powershell", "-NoLogo", "-NoProfile", "-Command", "Set-Content -LiteralPath $env:TEMP\\ro_tmpfail.txt -Value 'x'"], WS_ROOT) @@ -287,21 +412,21 @@ def main() -> int: else: add("RO: TEMP write denied (PS, skipped)", True) - # 18. WS: curl version check — don't rely on stub, just succeed + # 19. WS: curl version check — don't rely on stub, just succeed if have("curl"): rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "curl --version"], WS_ROOT) add("WS: curl present (version prints)", rc == 0, f"rc={rc}, err={err}") else: add("WS: curl present (optional, skipped)", True) - # 19. Optional: ripgrep version + # 20. Optional: ripgrep version if have("rg"): rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "rg --version"], WS_ROOT) add("WS: rg --version (optional)", rc == 0, f"rc={rc}, err={err}") else: add("WS: rg --version (optional, skipped)", True) - # 20. Optional: git --version + # 21. Optional: git --version if have("git"): rc, out, err = run_sbx("workspace-write", ["git", "--version"], WS_ROOT) add("WS: git --version (optional)", rc == 0, f"rc={rc}, err={err}") diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index f6c4563e17..365ac37d6f 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -1,4 +1,20 @@ +use std::collections::HashMap; +use std::path::Path; + +pub struct ElevatedSandboxCaptureRequest<'a> { + pub policy_json_or_preset: &'a str, + pub sandbox_policy_cwd: &'a Path, + pub codex_home: &'a Path, + pub command: Vec, + pub cwd: &'a Path, + pub env_map: HashMap, + pub timeout_ms: Option, + pub use_private_desktop: bool, + pub proxy_enforced: bool, +} + mod windows_impl { + use super::ElevatedSandboxCaptureRequest; use crate::acl::allow_null_device; use crate::allow::compute_allow_paths; use crate::allow::AllowDenyPaths; @@ -203,15 +219,19 @@ mod windows_impl { /// Launches the command runner under the sandbox user and captures its output. #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture( - policy_json_or_preset: &str, - sandbox_policy_cwd: &Path, - codex_home: &Path, - command: Vec, - cwd: &Path, - mut env_map: HashMap, - timeout_ms: Option, - use_private_desktop: bool, + request: ElevatedSandboxCaptureRequest<'_>, ) -> Result { + let ElevatedSandboxCaptureRequest { + policy_json_or_preset, + sandbox_policy_cwd, + codex_home, + command, + cwd, + mut env_map, + timeout_ms, + use_private_desktop, + proxy_enforced, + } = request; let policy = parse_policy(policy_json_or_preset)?; normalize_null_device_env(&mut env_map); ensure_non_interactive_pager(&mut env_map); @@ -224,8 +244,14 @@ mod windows_impl { let logs_base_dir: Option<&Path> = Some(sandbox_base.as_path()); log_start(&command, logs_base_dir); - let sandbox_creds = - require_logon_sandbox_creds(&policy, sandbox_policy_cwd, cwd, &env_map, codex_home)?; + let sandbox_creds = require_logon_sandbox_creds( + &policy, + sandbox_policy_cwd, + cwd, + &env_map, + codex_home, + proxy_enforced, + )?; let sandbox_sid = resolve_sid(&sandbox_creds.username).map_err(|err: anyhow::Error| { io::Error::new(io::ErrorKind::PermissionDenied, err.to_string()) })?; @@ -480,11 +506,9 @@ pub use windows_impl::run_windows_sandbox_capture; #[cfg(not(target_os = "windows"))] mod stub { + use super::ElevatedSandboxCaptureRequest; use anyhow::bail; use anyhow::Result; - use codex_protocol::protocol::SandboxPolicy; - use std::collections::HashMap; - use std::path::Path; #[derive(Debug, Default)] pub struct CaptureResult { @@ -497,14 +521,7 @@ mod stub { /// Stub implementation for non-Windows targets; sandboxing only works on Windows. #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture( - _policy_json_or_preset: &str, - _sandbox_policy_cwd: &Path, - _codex_home: &Path, - _command: Vec, - _cwd: &Path, - _env_map: HashMap, - _timeout_ms: Option, - _use_private_desktop: bool, + _request: ElevatedSandboxCaptureRequest<'_>, ) -> Result { bail!("Windows sandbox is only available on Windows") } diff --git a/codex-rs/windows-sandbox-rs/src/firewall.rs b/codex-rs/windows-sandbox-rs/src/firewall.rs index b4939fd42b..a95d3b57e2 100644 --- a/codex-rs/windows-sandbox-rs/src/firewall.rs +++ b/codex-rs/windows-sandbox-rs/src/firewall.rs @@ -9,10 +9,13 @@ use windows::core::BSTR; use windows::Win32::Foundation::VARIANT_TRUE; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule3; +use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRules; use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2; use windows::Win32::NetworkManagement::WindowsFirewall::NetFwRule; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_ACTION_BLOCK; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_ANY; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_TCP; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_UDP; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_PROFILE2_ALL; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_RULE_DIR_OUT; use windows::Win32::System::Com::CoCreateInstance; @@ -27,9 +30,128 @@ use codex_windows_sandbox::SetupFailure; // This is the stable identifier we use to find/update the rule idempotently. // It intentionally does not change between installs. const OFFLINE_BLOCK_RULE_NAME: &str = "codex_sandbox_offline_block_outbound"; +const OFFLINE_BLOCK_LOOPBACK_TCP_RULE_NAME: &str = "codex_sandbox_offline_block_loopback_tcp"; +const OFFLINE_BLOCK_LOOPBACK_UDP_RULE_NAME: &str = "codex_sandbox_offline_block_loopback_udp"; // Friendly text shown in the firewall UI. -const OFFLINE_BLOCK_RULE_FRIENDLY: &str = "Codex Sandbox Offline - Block Outbound"; +const OFFLINE_BLOCK_RULE_FRIENDLY: &str = "Codex Sandbox Offline - Block Non-Loopback Outbound"; +const OFFLINE_BLOCK_LOOPBACK_TCP_RULE_FRIENDLY: &str = + "Codex Sandbox Offline - Block Loopback TCP (Except Proxy)"; +const OFFLINE_BLOCK_LOOPBACK_UDP_RULE_FRIENDLY: &str = "Codex Sandbox Offline - Block Loopback UDP"; +const OFFLINE_PROXY_ALLOW_RULE_NAME: &str = "codex_sandbox_offline_allow_loopback_proxy"; +const LOOPBACK_REMOTE_ADDRESSES: &str = "127.0.0.0/8,::1"; +const NON_LOOPBACK_REMOTE_ADDRESSES: &str = + "0.0.0.0-126.255.255.255,128.0.0.0-255.255.255.255,::,::2-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"; + +struct BlockRuleSpec<'a> { + internal_name: &'a str, + friendly_desc: &'a str, + protocol: i32, + local_user_spec: &'a str, + offline_sid: &'a str, + remote_addresses: Option<&'a str>, + remote_ports: Option<&'a str>, +} + +pub fn ensure_offline_proxy_allowlist( + offline_sid: &str, + proxy_ports: &[u16], + allow_local_binding: bool, + log: &mut File, +) -> Result<()> { + let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})"); + + let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + if hr.is_err() { + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallComInitFailed, + format!("CoInitializeEx failed: {hr:?}"), + ))); + } + + let result = unsafe { + (|| -> Result<()> { + let policy: INetFwPolicy2 = CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER) + .map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallPolicyAccessFailed, + format!("CoCreateInstance NetFwPolicy2 failed: {err:?}"), + )) + })?; + let rules = policy.Rules().map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallPolicyAccessFailed, + format!("INetFwPolicy2::Rules failed: {err:?}"), + )) + })?; + + if allow_local_binding { + // Remove the legacy overlapping allow rule before returning to the local-binding + // mode so stale proxy exceptions do not linger. + remove_rule_if_present(&rules, OFFLINE_PROXY_ALLOW_RULE_NAME, log)?; + remove_rule_if_present(&rules, OFFLINE_BLOCK_LOOPBACK_UDP_RULE_NAME, log)?; + remove_rule_if_present(&rules, OFFLINE_BLOCK_LOOPBACK_TCP_RULE_NAME, log)?; + return Ok(()); + } + + ensure_block_rule( + &rules, + &BlockRuleSpec { + internal_name: OFFLINE_BLOCK_LOOPBACK_UDP_RULE_NAME, + friendly_desc: OFFLINE_BLOCK_LOOPBACK_UDP_RULE_FRIENDLY, + protocol: NET_FW_IP_PROTOCOL_UDP.0, + local_user_spec: &local_user_spec, + offline_sid, + remote_addresses: Some(LOOPBACK_REMOTE_ADDRESSES), + remote_ports: None, + }, + log, + )?; + + // Install a broad TCP loopback block before narrowing it to the allowed proxy port + // complement. If the narrowing update fails, the sandbox remains fail-closed. + ensure_block_rule( + &rules, + &BlockRuleSpec { + internal_name: OFFLINE_BLOCK_LOOPBACK_TCP_RULE_NAME, + friendly_desc: OFFLINE_BLOCK_LOOPBACK_TCP_RULE_FRIENDLY, + protocol: NET_FW_IP_PROTOCOL_TCP.0, + local_user_spec: &local_user_spec, + offline_sid, + remote_addresses: Some(LOOPBACK_REMOTE_ADDRESSES), + remote_ports: None, + }, + log, + )?; + + // Remove the legacy overlapping allow rule only after the explicit block rules are in + // place so transitions back to proxy-only mode do not fail open. + remove_rule_if_present(&rules, OFFLINE_PROXY_ALLOW_RULE_NAME, log)?; + + if let Some(blocked_remote_ports) = blocked_loopback_tcp_remote_ports(proxy_ports) { + ensure_block_rule( + &rules, + &BlockRuleSpec { + internal_name: OFFLINE_BLOCK_LOOPBACK_TCP_RULE_NAME, + friendly_desc: OFFLINE_BLOCK_LOOPBACK_TCP_RULE_FRIENDLY, + protocol: NET_FW_IP_PROTOCOL_TCP.0, + local_user_spec: &local_user_spec, + offline_sid, + remote_addresses: Some(LOOPBACK_REMOTE_ADDRESSES), + remote_ports: Some(&blocked_remote_ports), + }, + log, + )?; + } + Ok(()) + })() + }; + + unsafe { + CoUninitialize(); + } + result +} pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Result<()> { let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})"); @@ -61,11 +183,15 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul // Block all outbound IP protocols for this user. ensure_block_rule( &rules, - OFFLINE_BLOCK_RULE_NAME, - OFFLINE_BLOCK_RULE_FRIENDLY, - NET_FW_IP_PROTOCOL_ANY.0, - &local_user_spec, - offline_sid, + &BlockRuleSpec { + internal_name: OFFLINE_BLOCK_RULE_NAME, + friendly_desc: OFFLINE_BLOCK_RULE_FRIENDLY, + protocol: NET_FW_IP_PROTOCOL_ANY.0, + local_user_spec: &local_user_spec, + offline_sid, + remote_addresses: Some(NON_LOOPBACK_REMOTE_ADDRESSES), + remote_ports: None, + }, log, )?; Ok(()) @@ -78,16 +204,22 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul result } -fn ensure_block_rule( - rules: &windows::Win32::NetworkManagement::WindowsFirewall::INetFwRules, - internal_name: &str, - friendly_desc: &str, - protocol: i32, - local_user_spec: &str, - offline_sid: &str, - log: &mut File, -) -> Result<()> { +fn remove_rule_if_present(rules: &INetFwRules, internal_name: &str, log: &mut File) -> Result<()> { let name = BSTR::from(internal_name); + if unsafe { rules.Item(&name) }.is_ok() { + unsafe { rules.Remove(&name) }.map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, + format!("Rules::Remove failed for {internal_name}: {err:?}"), + )) + })?; + log_line(log, &format!("firewall rule removed name={internal_name}"))?; + } + Ok(()) +} + +fn ensure_block_rule(rules: &INetFwRules, spec: &BlockRuleSpec<'_>, log: &mut File) -> Result<()> { + let name = BSTR::from(spec.internal_name); let rule: INetFwRule3 = match unsafe { rules.Item(&name) } { Ok(existing) => existing.cast().map_err(|err| { anyhow::Error::new(SetupFailure::new( @@ -112,13 +244,7 @@ fn ensure_block_rule( )) })?; // Set all properties before adding the rule so we don't leave half-configured rules. - configure_rule( - &new_rule, - friendly_desc, - protocol, - local_user_spec, - offline_sid, - )?; + configure_rule(&new_rule, spec)?; unsafe { rules.Add(&new_rule) }.map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, @@ -130,26 +256,24 @@ fn ensure_block_rule( }; // Always re-apply fields to keep the setup idempotent. - configure_rule(&rule, friendly_desc, protocol, local_user_spec, offline_sid)?; + configure_rule(&rule, spec)?; + + let remote_addresses_log = spec.remote_addresses.unwrap_or("*"); + let remote_ports_log = spec.remote_ports.unwrap_or("*"); log_line( log, &format!( - "firewall rule configured name={internal_name} protocol={protocol} LocalUserAuthorizedList={local_user_spec}" + "firewall rule configured name={} protocol={} RemoteAddresses={remote_addresses_log} RemotePorts={remote_ports_log} LocalUserAuthorizedList={}", + spec.internal_name, spec.protocol, spec.local_user_spec ), )?; Ok(()) } -fn configure_rule( - rule: &INetFwRule3, - friendly_desc: &str, - protocol: i32, - local_user_spec: &str, - offline_sid: &str, -) -> Result<()> { +fn configure_rule(rule: &INetFwRule3, spec: &BlockRuleSpec<'_>) -> Result<()> { unsafe { - rule.SetDescription(&BSTR::from(friendly_desc)) + rule.SetDescription(&BSTR::from(spec.friendly_desc)) .map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, @@ -180,13 +304,29 @@ fn configure_rule( format!("SetProfiles failed: {err:?}"), )) })?; - rule.SetProtocol(protocol).map_err(|err| { + rule.SetProtocol(spec.protocol).map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, format!("SetProtocol failed: {err:?}"), )) })?; - rule.SetLocalUserAuthorizedList(&BSTR::from(local_user_spec)) + let remote_addresses = spec.remote_addresses.unwrap_or("*"); + rule.SetRemoteAddresses(&BSTR::from(remote_addresses)) + .map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, + format!("SetRemoteAddresses failed: {err:?}"), + )) + })?; + let remote_ports = spec.remote_ports.unwrap_or("*"); + rule.SetRemotePorts(&BSTR::from(remote_ports)) + .map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, + format!("SetRemotePorts failed: {err:?}"), + )) + })?; + rule.SetLocalUserAuthorizedList(&BSTR::from(spec.local_user_spec)) .map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, @@ -203,17 +343,59 @@ fn configure_rule( )) })?; let actual_str = actual.to_string(); - if !actual_str.contains(offline_sid) { + if !actual_str.contains(spec.offline_sid) { return Err(anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallRuleVerifyFailed, format!( - "offline firewall rule user scope mismatch: expected SID {offline_sid}, got {actual_str}" + "offline firewall rule user scope mismatch: expected SID {}, got {actual_str}", + spec.offline_sid ), ))); } Ok(()) } +fn blocked_loopback_tcp_remote_ports(proxy_ports: &[u16]) -> Option { + let mut allowed_ports = proxy_ports + .iter() + .copied() + .filter(|port| *port != 0) + .collect::>(); + allowed_ports.sort_unstable(); + allowed_ports.dedup(); + + let mut blocked_ranges = Vec::new(); + let mut start = 1_u32; + for port in allowed_ports { + let port = u32::from(port); + if port < start { + continue; + } + if port > start { + blocked_ranges.push(port_range_string(start, port - 1)); + } + start = port + 1; + } + + if start <= u32::from(u16::MAX) { + blocked_ranges.push(port_range_string(start, u32::from(u16::MAX))); + } + + if blocked_ranges.is_empty() { + None + } else { + Some(blocked_ranges.join(",")) + } +} + +fn port_range_string(start: u32, end: u32) -> String { + if start == end { + start.to_string() + } else { + format!("{start}-{end}") + } +} + fn log_line(log: &mut File, msg: &str) -> Result<()> { let ts = chrono::Utc::now().to_rfc3339(); writeln!(log, "[{ts}] {msg}")?; diff --git a/codex-rs/windows-sandbox-rs/src/identity.rs b/codex-rs/windows-sandbox-rs/src/identity.rs index b9d5e29ed7..b7768c1a3e 100644 --- a/codex-rs/windows-sandbox-rs/src/identity.rs +++ b/codex-rs/windows-sandbox-rs/src/identity.rs @@ -3,9 +3,11 @@ use crate::logging::debug_log; use crate::policy::SandboxPolicy; use crate::setup::gather_read_roots; use crate::setup::gather_write_roots; +use crate::setup::offline_proxy_settings_from_env; use crate::setup::run_elevated_setup; use crate::setup::sandbox_users_path; use crate::setup::setup_marker_path; +use crate::setup::SandboxNetworkIdentity; use crate::setup::SandboxUserRecord; use crate::setup::SandboxUsersFile; use crate::setup::SetupMarker; @@ -33,7 +35,8 @@ pub struct SandboxCreds { /// Returns true when the on-disk setup artifacts exist and match the current /// setup version. /// -/// This reuses the same marker/users validation used by `require_logon_sandbox_creds`. +/// This is a coarse readiness check; `require_logon_sandbox_creds` performs the +/// additional runtime validation for offline firewall settings. pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool { let marker_ok = matches!(load_marker(codex_home), Ok(Some(marker)) if marker.version_matches()); if !marker_ok { @@ -101,7 +104,10 @@ fn decode_password(record: &SandboxUserRecord) -> Result { Ok(pwd) } -fn select_identity(policy: &SandboxPolicy, codex_home: &Path) -> Result> { +fn select_identity( + network_identity: SandboxNetworkIdentity, + codex_home: &Path, +) -> Result> { let _marker = match load_marker(codex_home)? { Some(m) if m.version_matches() => m, _ => return Ok(None), @@ -110,10 +116,9 @@ fn select_identity(policy: &SandboxPolicy, codex_home: &Path) -> Result u, _ => return Ok(None), }; - let chosen = if !policy.has_full_network_access() { - users.offline - } else { - users.online + let chosen = match network_identity { + SandboxNetworkIdentity::Offline => users.offline, + SandboxNetworkIdentity::Online => users.online, }; let password = decode_password(&chosen)?; Ok(Some(SandboxIdentity { @@ -128,25 +133,34 @@ pub fn require_logon_sandbox_creds( command_cwd: &Path, env_map: &HashMap, codex_home: &Path, + proxy_enforced: bool, ) -> Result { let sandbox_dir = crate::setup::sandbox_dir(codex_home); let needed_read = gather_read_roots(command_cwd, policy, codex_home); let needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map); + let network_identity = SandboxNetworkIdentity::from_policy(policy, proxy_enforced); + let desired_offline_proxy_settings = + offline_proxy_settings_from_env(env_map, network_identity); // NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the // restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for // granting the sandbox group access to this directory without granting the capability SID. let mut setup_reason: Option = None; - let mut _existing_marker: Option = None; let mut identity = match load_marker(codex_home)? { Some(marker) if marker.version_matches() => { - _existing_marker = Some(marker.clone()); - let selected = select_identity(policy, codex_home)?; - if selected.is_none() { - setup_reason = - Some("sandbox users missing or incompatible with marker version".to_string()); + if let Some(reason) = + marker.request_mismatch_reason(network_identity, &desired_offline_proxy_settings) + { + setup_reason = Some(reason); + None + } else { + let selected = select_identity(network_identity, codex_home)?; + if selected.is_none() { + setup_reason = + Some("sandbox users missing or incompatible with marker version".to_string()); + } + selected } - selected } _ => { setup_reason = Some("sandbox setup marker missing or incompatible".to_string()); @@ -164,18 +178,30 @@ pub fn require_logon_sandbox_creds( crate::logging::log_note("sandbox setup required", Some(&sandbox_dir)); } run_elevated_setup( - policy, - policy_cwd, - command_cwd, - env_map, - codex_home, - Some(needed_read.clone()), - Some(needed_write.clone()), + crate::setup::SandboxSetupRequest { + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + proxy_enforced, + }, + crate::setup::SetupRootOverrides { + read_roots: Some(needed_read.clone()), + write_roots: Some(needed_write.clone()), + }, )?; - identity = select_identity(policy, codex_home)?; + identity = select_identity(network_identity, codex_home)?; } // Always refresh ACLs (non-elevated) for current roots via the setup binary. - crate::setup::run_setup_refresh(policy, policy_cwd, command_cwd, env_map, codex_home)?; + crate::setup::run_setup_refresh( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + proxy_enforced, + )?; let identity = identity.ok_or_else(|| { anyhow!( "Windows sandbox setup is missing or out of date; rerun the sandbox setup with elevation" diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 73193a207d..11afda750d 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -72,6 +72,8 @@ pub use dpapi::unprotect as dpapi_unprotect; #[cfg(target_os = "windows")] pub use elevated_impl::run_windows_sandbox_capture as run_windows_sandbox_capture_elevated; #[cfg(target_os = "windows")] +pub use elevated_impl::ElevatedSandboxCaptureRequest; +#[cfg(target_os = "windows")] pub use helper_materialization::resolve_current_exe_for_launch; #[cfg(target_os = "windows")] pub use hide_users::hide_current_user_profile_dir; @@ -116,6 +118,10 @@ pub use setup::sandbox_dir; #[cfg(target_os = "windows")] pub use setup::sandbox_secrets_dir; #[cfg(target_os = "windows")] +pub use setup::SandboxSetupRequest; +#[cfg(target_os = "windows")] +pub use setup::SetupRootOverrides; +#[cfg(target_os = "windows")] pub use setup::SETUP_VERSION; #[cfg(target_os = "windows")] pub use setup_error::extract_failure as extract_setup_failure; diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs index 5ab39c3cec..e006b696fb 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs @@ -63,6 +63,8 @@ pub fn provision_sandbox_users( codex_home: &Path, offline_username: &str, online_username: &str, + proxy_ports: &[u16], + allow_local_binding: bool, log: &mut File, ) -> Result<()> { ensure_sandbox_users_group(log)?; @@ -80,6 +82,8 @@ pub fn provision_sandbox_users( &offline_password, online_username, &online_password, + proxy_ports, + allow_local_binding, )?; Ok(()) } @@ -388,6 +392,8 @@ struct SetupMarker { offline_username: String, online_username: String, created_at: String, + proxy_ports: Vec, + allow_local_binding: bool, read_roots: Vec, write_roots: Vec, } @@ -398,6 +404,8 @@ fn write_secrets( offline_pwd: &str, online_user: &str, online_pwd: &str, + proxy_ports: &[u16], + allow_local_binding: bool, ) -> Result<()> { let sandbox_dir = sandbox_dir(codex_home); std::fs::create_dir_all(&sandbox_dir).map_err(|err| { @@ -447,6 +455,8 @@ fn write_secrets( offline_username: offline_user.to_string(), online_username: online_user.to_string(), created_at: chrono::Utc::now().to_rfc3339(), + proxy_ports: proxy_ports.to_vec(), + allow_local_binding, read_roots: Vec::new(), write_roots: Vec::new(), }; diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs index 86c1d104e0..8a72b22a91 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -84,6 +84,10 @@ struct Payload { command_cwd: PathBuf, read_roots: Vec, write_roots: Vec, + #[serde(default)] + proxy_ports: Vec, + #[serde(default)] + allow_local_binding: bool, real_user: String, #[serde(default)] mode: SetupMode, @@ -510,6 +514,8 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( &payload.codex_home, &payload.offline_username, &payload.online_username, + &payload.proxy_ports, + payload.allow_local_binding, log, ); if let Err(err) = provision_result { @@ -572,6 +578,21 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( }; let mut refresh_errors: Vec = Vec::new(); if !refresh_only { + let proxy_allowlist_result = firewall::ensure_offline_proxy_allowlist( + &offline_sid_str, + &payload.proxy_ports, + payload.allow_local_binding, + log, + ); + if let Err(err) = proxy_allowlist_result { + if extract_setup_failure(&err).is_some() { + return Err(err); + } + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, + format!("ensure offline proxy allowlist failed: {err}"), + ))); + } let firewall_result = firewall::ensure_offline_outbound_block(&offline_sid_str, log); if let Err(err) = firewall_result { if extract_setup_failure(&err).is_some() { diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 3296d09c43..962a53d60c 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use serde::Serialize; +use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::c_void; @@ -78,21 +79,39 @@ pub fn sandbox_users_path(codex_home: &Path) -> PathBuf { sandbox_secrets_dir(codex_home).join("sandbox_users.json") } +pub struct SandboxSetupRequest<'a> { + pub policy: &'a SandboxPolicy, + pub policy_cwd: &'a Path, + pub command_cwd: &'a Path, + pub env_map: &'a HashMap, + pub codex_home: &'a Path, + pub proxy_enforced: bool, +} + +#[derive(Default)] +pub struct SetupRootOverrides { + pub read_roots: Option>, + pub write_roots: Option>, +} + pub fn run_setup_refresh( policy: &SandboxPolicy, policy_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, + proxy_enforced: bool, ) -> Result<()> { run_setup_refresh_inner( - policy, - policy_cwd, - command_cwd, - env_map, - codex_home, - /*read_roots_override*/ None, - /*write_roots_override*/ None, + SandboxSetupRequest { + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + proxy_enforced, + }, + SetupRootOverrides::default(), ) } @@ -103,53 +122,51 @@ pub fn run_setup_refresh_with_extra_read_roots( env_map: &HashMap, codex_home: &Path, extra_read_roots: Vec, + proxy_enforced: bool, ) -> Result<()> { let mut read_roots = gather_read_roots(command_cwd, policy, codex_home); read_roots.extend(extra_read_roots); run_setup_refresh_inner( - policy, - policy_cwd, - command_cwd, - env_map, - codex_home, - Some(read_roots), - Some(Vec::new()), + SandboxSetupRequest { + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + proxy_enforced, + }, + SetupRootOverrides { + read_roots: Some(read_roots), + write_roots: Some(Vec::new()), + }, ) } fn run_setup_refresh_inner( - policy: &SandboxPolicy, - policy_cwd: &Path, - command_cwd: &Path, - env_map: &HashMap, - codex_home: &Path, - read_roots_override: Option>, - write_roots_override: Option>, + request: SandboxSetupRequest<'_>, + overrides: SetupRootOverrides, ) -> Result<()> { // Skip in danger-full-access. if matches!( - policy, + request.policy, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) { return Ok(()); } - let (read_roots, write_roots) = build_payload_roots( - policy, - policy_cwd, - command_cwd, - env_map, - codex_home, - read_roots_override, - write_roots_override, - ); + let (read_roots, write_roots) = build_payload_roots(&request, overrides); + let network_identity = + SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced); + let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity); let payload = ElevationPayload { version: SETUP_VERSION, offline_username: OFFLINE_USERNAME.to_string(), online_username: ONLINE_USERNAME.to_string(), - codex_home: codex_home.to_path_buf(), - command_cwd: command_cwd.to_path_buf(), + codex_home: request.codex_home.to_path_buf(), + command_cwd: request.command_cwd.to_path_buf(), read_roots, write_roots, + proxy_ports: offline_proxy_settings.proxy_ports, + allow_local_binding: offline_proxy_settings.allow_local_binding, real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()), refresh_only: true, }; @@ -159,7 +176,7 @@ fn run_setup_refresh_inner( // Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC. let mut cmd = Command::new(&exe); cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null()); - let cwd = std::env::current_dir().unwrap_or_else(|_| codex_home.to_path_buf()); + let cwd = std::env::current_dir().unwrap_or_else(|_| request.codex_home.to_path_buf()); log_note( &format!( "setup refresh: spawning {} (cwd={}, payload_len={})", @@ -167,14 +184,14 @@ fn run_setup_refresh_inner( cwd.display(), b64.len() ), - Some(&sandbox_dir(codex_home)), + Some(&sandbox_dir(request.codex_home)), ); let status = cmd .status() .map_err(|e| { log_note( &format!("setup refresh: failed to spawn {}: {e}", exe.display()), - Some(&sandbox_dir(codex_home)), + Some(&sandbox_dir(request.codex_home)), ); e }) @@ -182,7 +199,7 @@ fn run_setup_refresh_inner( if !status.success() { log_note( &format!("setup refresh: exited with status {status:?}"), - Some(&sandbox_dir(codex_home)), + Some(&sandbox_dir(request.codex_home)), ); return Err(anyhow!("setup refresh failed with status {}", status)); } @@ -196,12 +213,38 @@ pub struct SetupMarker { pub online_username: String, #[serde(default)] pub created_at: Option, + #[serde(default)] + pub proxy_ports: Vec, + #[serde(default)] + pub allow_local_binding: bool, } impl SetupMarker { pub fn version_matches(&self) -> bool { self.version == SETUP_VERSION } + + pub(crate) fn request_mismatch_reason( + &self, + network_identity: SandboxNetworkIdentity, + offline_proxy_settings: &OfflineProxySettings, + ) -> Option { + if !network_identity.uses_offline_identity() { + return None; + } + if self.proxy_ports == offline_proxy_settings.proxy_ports + && self.allow_local_binding == offline_proxy_settings.allow_local_binding + { + return None; + } + Some(format!( + "offline firewall settings changed (stored_ports={:?}, desired_ports={:?}, stored_allow_local_binding={}, desired_allow_local_binding={})", + self.proxy_ports, + offline_proxy_settings.proxy_ports, + self.allow_local_binding, + offline_proxy_settings.allow_local_binding + )) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -390,11 +433,106 @@ struct ElevationPayload { command_cwd: PathBuf, read_roots: Vec, write_roots: Vec, + #[serde(default)] + proxy_ports: Vec, + #[serde(default)] + allow_local_binding: bool, real_user: String, #[serde(default)] refresh_only: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OfflineProxySettings { + pub proxy_ports: Vec, + pub allow_local_binding: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SandboxNetworkIdentity { + Offline, + Online, +} + +impl SandboxNetworkIdentity { + pub(crate) fn from_policy(policy: &SandboxPolicy, proxy_enforced: bool) -> Self { + if proxy_enforced || !policy.has_full_network_access() { + Self::Offline + } else { + Self::Online + } + } + + pub(crate) fn uses_offline_identity(self) -> bool { + matches!(self, Self::Offline) + } +} + +const PROXY_ENV_KEYS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "WS_PROXY", + "WSS_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "ws_proxy", + "wss_proxy", +]; +const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; + +pub(crate) fn offline_proxy_settings_from_env( + env_map: &HashMap, + network_identity: SandboxNetworkIdentity, +) -> OfflineProxySettings { + if !network_identity.uses_offline_identity() { + return OfflineProxySettings { + proxy_ports: vec![], + allow_local_binding: false, + }; + } + OfflineProxySettings { + proxy_ports: proxy_ports_from_env(env_map), + allow_local_binding: env_map + .get(ALLOW_LOCAL_BINDING_ENV_KEY) + .is_some_and(|value| value == "1"), + } +} + +pub(crate) fn proxy_ports_from_env(env_map: &HashMap) -> Vec { + let mut ports = BTreeSet::new(); + for key in PROXY_ENV_KEYS { + if let Some(value) = env_map.get(*key) { + if let Some(port) = loopback_proxy_port_from_url(value) { + ports.insert(port); + } + } + } + ports.into_iter().collect() +} + +fn loopback_proxy_port_from_url(url: &str) -> Option { + let authority = url.trim().split_once("://")?.1.split('/').next()?; + let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp); + + if let Some(host) = host_port.strip_prefix('[') { + let (host, rest) = host.split_once(']')?; + if host != "::1" { + return None; + } + let port = rest.strip_prefix(':')?.parse::().ok()?; + return (port != 0).then_some(port); + } + + let (host, port) = host_port.rsplit_once(':')?; + if !(host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1") { + return None; + } + let port = port.parse::().ok()?; + (port != 0).then_some(port) +} + fn quote_arg(arg: &str) -> String { let needs = arg.is_empty() || arg @@ -574,39 +712,31 @@ fn run_setup_exe( } pub fn run_elevated_setup( - policy: &SandboxPolicy, - policy_cwd: &Path, - command_cwd: &Path, - env_map: &HashMap, - codex_home: &Path, - read_roots_override: Option>, - write_roots_override: Option>, + request: SandboxSetupRequest<'_>, + overrides: SetupRootOverrides, ) -> Result<()> { // Ensure the shared sandbox directory exists before we send it to the elevated helper. - let sbx_dir = sandbox_dir(codex_home); + let sbx_dir = sandbox_dir(request.codex_home); std::fs::create_dir_all(&sbx_dir).map_err(|err| { failure( SetupErrorCode::OrchestratorSandboxDirCreateFailed, format!("failed to create sandbox dir {}: {err}", sbx_dir.display()), ) })?; - let (read_roots, write_roots) = build_payload_roots( - policy, - policy_cwd, - command_cwd, - env_map, - codex_home, - read_roots_override, - write_roots_override, - ); + let (read_roots, write_roots) = build_payload_roots(&request, overrides); + let network_identity = + SandboxNetworkIdentity::from_policy(request.policy, request.proxy_enforced); + let offline_proxy_settings = offline_proxy_settings_from_env(request.env_map, network_identity); let payload = ElevationPayload { version: SETUP_VERSION, offline_username: OFFLINE_USERNAME.to_string(), online_username: ONLINE_USERNAME.to_string(), - codex_home: codex_home.to_path_buf(), - command_cwd: command_cwd.to_path_buf(), + codex_home: request.codex_home.to_path_buf(), + command_cwd: request.command_cwd.to_path_buf(), read_roots, write_roots, + proxy_ports: offline_proxy_settings.proxy_ports, + allow_local_binding: offline_proxy_settings.allow_local_binding, real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()), refresh_only: false, }; @@ -616,28 +746,28 @@ pub fn run_elevated_setup( format!("failed to determine elevation state: {err}"), ) })?; - run_setup_exe(&payload, needs_elevation, codex_home) + run_setup_exe(&payload, needs_elevation, request.codex_home) } fn build_payload_roots( - policy: &SandboxPolicy, - policy_cwd: &Path, - command_cwd: &Path, - env_map: &HashMap, - codex_home: &Path, - read_roots_override: Option>, - write_roots_override: Option>, + request: &SandboxSetupRequest<'_>, + overrides: SetupRootOverrides, ) -> (Vec, Vec) { - let write_roots = if let Some(roots) = write_roots_override { + let write_roots = if let Some(roots) = overrides.write_roots { canonical_existing(&roots) } else { - gather_write_roots(policy, policy_cwd, command_cwd, env_map) + gather_write_roots( + request.policy, + request.policy_cwd, + request.command_cwd, + request.env_map, + ) }; - let write_roots = filter_sensitive_write_roots(write_roots, codex_home); - let mut read_roots = if let Some(roots) = read_roots_override { + let write_roots = filter_sensitive_write_roots(write_roots, request.codex_home); + let mut read_roots = if let Some(roots) = overrides.read_roots { canonical_existing(&roots) } else { - gather_read_roots(command_cwd, policy, codex_home) + gather_read_roots(request.command_cwd, request.policy, request.codex_home) }; let write_root_set: HashSet = write_roots.iter().cloned().collect(); read_roots.retain(|root| !write_root_set.contains(root)); @@ -673,13 +803,17 @@ fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> V mod tests { use super::gather_legacy_full_read_roots; use super::gather_read_roots; + use super::loopback_proxy_port_from_url; + use super::offline_proxy_settings_from_env; use super::profile_read_roots; + use super::proxy_ports_from_env; use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS; use crate::helper_materialization::helper_bin_dir; use crate::policy::SandboxPolicy; use codex_protocol::protocol::ReadOnlyAccess; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::path::PathBuf; @@ -692,6 +826,143 @@ mod tests { .collect() } + #[test] + fn loopback_proxy_url_parsing_supports_common_forms() { + assert_eq!( + loopback_proxy_port_from_url("http://localhost:3128"), + Some(3128) + ); + assert_eq!( + loopback_proxy_port_from_url("https://127.0.0.1:8080"), + Some(8080) + ); + assert_eq!( + loopback_proxy_port_from_url("socks5h://user:pass@[::1]:1080"), + Some(1080) + ); + } + + #[test] + fn loopback_proxy_url_parsing_rejects_non_loopback_and_zero_port() { + assert_eq!( + loopback_proxy_port_from_url("http://example.com:3128"), + None + ); + assert_eq!(loopback_proxy_port_from_url("http://127.0.0.1:0"), None); + assert_eq!(loopback_proxy_port_from_url("localhost:8080"), None); + } + + #[test] + fn proxy_ports_from_env_dedupes_and_sorts() { + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:8080".to_string(), + ); + env.insert( + "http_proxy".to_string(), + "http://localhost:8080".to_string(), + ); + env.insert("ALL_PROXY".to_string(), "socks5h://[::1]:1081".to_string()); + env.insert( + "HTTPS_PROXY".to_string(), + "https://example.com:9999".to_string(), + ); + + assert_eq!(proxy_ports_from_env(&env), vec![1081, 8080]); + } + + #[test] + fn offline_proxy_settings_ignore_proxy_env_when_online_identity_selected() { + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:8080".to_string(), + ); + env.insert( + "CODEX_NETWORK_ALLOW_LOCAL_BINDING".to_string(), + "1".to_string(), + ); + + assert_eq!( + offline_proxy_settings_from_env(&env, super::SandboxNetworkIdentity::Online), + super::OfflineProxySettings { + proxy_ports: vec![], + allow_local_binding: false, + } + ); + } + + #[test] + fn offline_proxy_settings_capture_proxy_ports_and_local_binding_for_offline_identity() { + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:8080".to_string(), + ); + env.insert( + "ALL_PROXY".to_string(), + "socks5h://127.0.0.1:1081".to_string(), + ); + env.insert( + "CODEX_NETWORK_ALLOW_LOCAL_BINDING".to_string(), + "1".to_string(), + ); + + assert_eq!( + offline_proxy_settings_from_env(&env, super::SandboxNetworkIdentity::Offline), + super::OfflineProxySettings { + proxy_ports: vec![1081, 8080], + allow_local_binding: true, + } + ); + } + + #[test] + fn setup_marker_request_mismatch_reason_ignores_proxy_drift_for_online_identity() { + let marker = super::SetupMarker { + version: super::SETUP_VERSION, + offline_username: "offline".to_string(), + online_username: "online".to_string(), + created_at: None, + proxy_ports: vec![3128], + allow_local_binding: false, + }; + let desired = super::OfflineProxySettings { + proxy_ports: vec![1081, 8080], + allow_local_binding: true, + }; + + assert_eq!( + marker.request_mismatch_reason(super::SandboxNetworkIdentity::Online, &desired), + None + ); + } + + #[test] + fn setup_marker_request_mismatch_reason_reports_offline_firewall_drift() { + let marker = super::SetupMarker { + version: super::SETUP_VERSION, + offline_username: "offline".to_string(), + online_username: "online".to_string(), + created_at: None, + proxy_ports: vec![3128], + allow_local_binding: false, + }; + let desired = super::OfflineProxySettings { + proxy_ports: vec![1081, 8080], + allow_local_binding: true, + }; + + assert_eq!( + marker.request_mismatch_reason(super::SandboxNetworkIdentity::Offline, &desired), + Some( + "offline firewall settings changed (stored_ports=[3128], desired_ports=[1081, 8080], stored_allow_local_binding=false, desired_allow_local_binding=true)" + .to_string() + ) + ); + } + #[test] fn profile_read_roots_excludes_configured_top_level_entries() { let tmp = TempDir::new().expect("tempdir"); From d76124d656424cd9a15c9ef9e901fc1a9f3679c1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Mar 2026 18:23:14 -0700 Subject: [PATCH 004/185] fix: make MACOS_DEFAULT_PREFERENCES_POLICY part of MACOS_SEATBELT_BASE_POLICY (#15931) --- codex-rs/sandboxing/src/seatbelt.rs | 9 --------- codex-rs/sandboxing/src/seatbelt_base_policy.sbpl | 8 ++++++++ codex-rs/sandboxing/src/seatbelt_tests.rs | 8 -------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index 5c11c4fc45..bb784bc0df 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -19,14 +19,6 @@ const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str = include_str!("restricted_read_only_platform_defaults.sbpl"); -const MACOS_DEFAULT_PREFERENCES_POLICY: &str = r#"; allow readonly user preferences -(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) -(allow mach-lookup - (global-name "com.apple.cfprefsd.daemon") - (global-name "com.apple.cfprefsd.agent") - (local-name "com.apple.cfprefsd.agent")) -(allow user-preference-read) -"#; /// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` /// to defend against an attacker trying to inject a malicious version on the @@ -476,7 +468,6 @@ pub fn create_seatbelt_command_args_for_policies( let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); let mut policy_sections = vec![ MACOS_SEATBELT_BASE_POLICY.to_string(), - MACOS_DEFAULT_PREFERENCES_POLICY.to_string(), file_read_policy, file_write_policy, network_policy, diff --git a/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl index 72b13c96cd..8d80277f6c 100644 --- a/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl +++ b/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl @@ -106,3 +106,11 @@ ; PTYs created before entering seatbelt may lack the extension; allow ioctl ; on those slave ttys so interactive shells detect a TTY and remain functional. (allow file-ioctl (regex #"^/dev/ttys[0-9]+")) + +; allow readonly user preferences +(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) +(allow mach-lookup + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent")) +(allow user-preference-read) diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index cc6fce56a4..b83ac31184 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -953,14 +953,6 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { // - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. let expected_policy = format!( r#"{MACOS_SEATBELT_BASE_POLICY} -; allow readonly user preferences -(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) -(allow mach-lookup - (global-name "com.apple.cfprefsd.daemon") - (global-name "com.apple.cfprefsd.agent") - (local-name "com.apple.cfprefsd.agent")) -(allow user-preference-read) - ; allow read-only file operations (allow file-read*) (allow file-write* From d838c23867f6fe2e3e43a0c82d25c17cb3730b1d Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Mar 2026 18:32:13 -0700 Subject: [PATCH 005/185] fix: use matrix.target instead of matrix.os for actions/cache build action (#15933) This seems like a more precise cache key. --- .github/workflows/bazel.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 1c12db52b1..79d963a537 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -86,9 +86,9 @@ jobs: with: path: | ~/.cache/bazel-repo-cache - key: bazel-cache-${{ matrix.os }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} + key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} restore-keys: | - bazel-cache-${{ matrix.os }} + bazel-cache-${{ matrix.target }} - name: Configure Bazel startup args (Windows) if: runner.os == 'Windows' @@ -230,4 +230,4 @@ jobs: with: path: | ~/.cache/bazel-repo-cache - key: bazel-cache-${{ matrix.os }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} + key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} From be5afc65d31728c11d67c589cebbc64a87979e74 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Mar 2026 19:57:26 -0700 Subject: [PATCH 006/185] codex-tools: extract MCP schema adapters (#15928) ## Why `codex-tools` already owns the shared tool input schema model and parser from the first extraction step, but `core/src/tools/spec.rs` still owned the MCP-specific adapter that normalizes `rmcp::model::Tool` schemas and wraps `structuredContent` into the call result output schema. Keeping that adapter in `codex-core` means the reusable MCP schema path is still split across crates, and the unit tests for that logic stay anchored in `codex-core` even though the runtime orchestration does not need to move yet. This change takes the next small step by moving the reusable MCP schema adapter into `codex-tools` while leaving `ResponsesApiTool` assembly in `codex-core`. ## What changed - added `tools/src/mcp_tool.rs` and sibling `tools/src/mcp_tool_tests.rs` - introduced `ParsedMcpTool`, `parse_mcp_tool()`, and `mcp_call_tool_result_output_schema()` in `codex-tools` - updated `core/src/tools/spec.rs` to consume parsed MCP tool parts from `codex-tools` - removed the now-redundant MCP schema unit tests from `core/src/tools/spec_tests.rs` - expanded `codex-rs/tools/README.md` to describe this second migration step ## Test plan - `cargo test -p codex-tools` - `cargo test -p codex-core --lib tools::spec::` --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/tools/spec.rs | 73 +++----------- codex-rs/core/src/tools/spec_tests.rs | 133 -------------------------- codex-rs/tools/Cargo.toml | 6 ++ codex-rs/tools/README.md | 7 +- codex-rs/tools/src/lib.rs | 4 + codex-rs/tools/src/mcp_tool.rs | 65 +++++++++++++ codex-rs/tools/src/mcp_tool_tests.rs | 120 +++++++++++++++++++++++ 8 files changed, 212 insertions(+), 197 deletions(-) create mode 100644 codex-rs/tools/src/mcp_tool.rs create mode 100644 codex-rs/tools/src/mcp_tool_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f494c8528b..c5846fbe19 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2633,6 +2633,7 @@ name = "codex-tools" version = "0.0.0" dependencies = [ "pretty_assertions", + "rmcp", "serde", "serde_json", ] diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a81bf5a3c3..ea85eaacdd 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -46,6 +46,7 @@ use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_tools::parse_mcp_tool; pub use codex_tools::parse_tool_input_schema; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; @@ -58,6 +59,9 @@ use std::path::PathBuf; pub type JsonSchema = codex_tools::JsonSchema; +#[cfg(test)] +pub(crate) use codex_tools::mcp_call_tool_result_output_schema; + const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str = @@ -2362,15 +2366,15 @@ pub(crate) fn mcp_tool_to_openai_tool( fully_qualified_name: String, tool: rmcp::model::Tool, ) -> Result { - let (description, input_schema, output_schema) = mcp_tool_to_openai_tool_parts(tool)?; + let parsed_tool = parse_mcp_tool(&tool)?; Ok(ResponsesApiTool { name: fully_qualified_name, - description, + description: parsed_tool.description, strict: false, defer_loading: None, - parameters: input_schema, - output_schema, + parameters: parsed_tool.input_schema, + output_schema: Some(parsed_tool.output_schema), }) } @@ -2378,14 +2382,14 @@ pub(crate) fn mcp_tool_to_deferred_openai_tool( name: String, tool: rmcp::model::Tool, ) -> Result { - let (description, input_schema, _) = mcp_tool_to_openai_tool_parts(tool)?; + let parsed_tool = parse_mcp_tool(&tool)?; Ok(ResponsesApiTool { name, - description, + description: parsed_tool.description, strict: false, defer_loading: Some(true), - parameters: input_schema, + parameters: parsed_tool.input_schema, output_schema: None, }) } @@ -2405,61 +2409,6 @@ fn dynamic_tool_to_openai_tool( }) } -fn mcp_tool_to_openai_tool_parts( - tool: rmcp::model::Tool, -) -> Result<(String, JsonSchema, Option), serde_json::Error> { - let rmcp::model::Tool { - description, - input_schema, - output_schema, - .. - } = tool; - - let mut serialized_input_schema = serde_json::Value::Object(input_schema.as_ref().clone()); - - // OpenAI models mandate the "properties" field in the schema. Some MCP - // servers omit it (or set it to null), so we insert an empty object to - // match the behavior of the Agents SDK. - if let serde_json::Value::Object(obj) = &mut serialized_input_schema - && obj.get("properties").is_none_or(serde_json::Value::is_null) - { - obj.insert( - "properties".to_string(), - serde_json::Value::Object(serde_json::Map::new()), - ); - } - - let input_schema = parse_tool_input_schema(&serialized_input_schema)?; - let structured_content_schema = output_schema - .map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone())) - .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())); - let output_schema = Some(mcp_call_tool_result_output_schema( - structured_content_schema, - )); - let description = description.map(Into::into).unwrap_or_default(); - - Ok((description, input_schema, output_schema)) -} - -fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue { - json!({ - "type": "object", - "properties": { - "content": { - "type": "array", - "items": {} - }, - "structuredContent": structured_content_schema, - "isError": { - "type": "boolean" - }, - "_meta": {} - }, - "required": ["content"], - "additionalProperties": false - }) -} - /// Builds the tool registry builder while collecting tool specs for later serialization. #[cfg(test)] pub(crate) fn build_specs( diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 82310e8726..5b9016ec6a 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -63,139 +63,6 @@ fn search_capable_model_info() -> ModelInfo { model_info } -#[test] -fn mcp_tool_to_openai_tool_inserts_empty_properties() { - let mut schema = rmcp::model::JsonObject::new(); - schema.insert("type".to_string(), serde_json::json!("object")); - - let tool = rmcp::model::Tool { - name: "no_props".to_string().into(), - title: None, - description: Some("No properties".to_string().into()), - input_schema: std::sync::Arc::new(schema), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }; - - let openai_tool = - mcp_tool_to_openai_tool("server/no_props".to_string(), tool).expect("convert tool"); - let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize schema"); - - assert_eq!(parameters.get("properties"), Some(&serde_json::json!({}))); -} - -#[test] -fn mcp_tool_to_openai_tool_preserves_top_level_output_schema() { - let mut input_schema = rmcp::model::JsonObject::new(); - input_schema.insert("type".to_string(), serde_json::json!("object")); - - let mut output_schema = rmcp::model::JsonObject::new(); - output_schema.insert( - "properties".to_string(), - serde_json::json!({ - "result": { - "properties": { - "nested": {} - } - } - }), - ); - output_schema.insert("required".to_string(), serde_json::json!(["result"])); - - let tool = rmcp::model::Tool { - name: "with_output".to_string().into(), - title: None, - description: Some("Has output schema".to_string().into()), - input_schema: std::sync::Arc::new(input_schema), - output_schema: Some(std::sync::Arc::new(output_schema)), - annotations: None, - execution: None, - icons: None, - meta: None, - }; - - let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_output".to_string(), tool) - .expect("convert tool"); - - assert_eq!( - openai_tool.output_schema, - Some(serde_json::json!({ - "type": "object", - "properties": { - "content": { - "type": "array", - "items": {} - }, - "structuredContent": { - "properties": { - "result": { - "properties": { - "nested": {} - } - } - }, - "required": ["result"] - }, - "isError": { - "type": "boolean" - }, - "_meta": {} - }, - "required": ["content"], - "additionalProperties": false - })) - ); -} - -#[test] -fn mcp_tool_to_openai_tool_preserves_output_schema_without_inferred_type() { - let mut input_schema = rmcp::model::JsonObject::new(); - input_schema.insert("type".to_string(), serde_json::json!("object")); - - let mut output_schema = rmcp::model::JsonObject::new(); - output_schema.insert("enum".to_string(), serde_json::json!(["ok", "error"])); - - let tool = rmcp::model::Tool { - name: "with_enum_output".to_string().into(), - title: None, - description: Some("Has enum output schema".to_string().into()), - input_schema: std::sync::Arc::new(input_schema), - output_schema: Some(std::sync::Arc::new(output_schema)), - annotations: None, - execution: None, - icons: None, - meta: None, - }; - - let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_enum_output".to_string(), tool) - .expect("convert tool"); - - assert_eq!( - openai_tool.output_schema, - Some(serde_json::json!({ - "type": "object", - "properties": { - "content": { - "type": "array", - "items": {} - }, - "structuredContent": { - "enum": ["ok", "error"] - }, - "isError": { - "type": "boolean" - }, - "_meta": {} - }, - "required": ["content"], - "additionalProperties": false - })) - ); -} - #[test] fn search_tool_deferred_tools_always_set_defer_loading_true() { let tool = mcp_tool( diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index c596520c57..d695336fae 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -8,6 +8,12 @@ version.workspace = true workspace = true [dependencies] +rmcp = { workspace = true, default-features = false, features = [ + "base64", + "macros", + "schemars", + "server", +] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index d4c2f28e91..b8780fe7b1 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -4,12 +4,15 @@ shared across multiple crates and does not need to stay coupled to `codex-core`. -Today this crate is intentionally small. It only owns the shared tool input -schema model and parser that were previously defined in `core/src/tools/spec.rs`: +Today this crate is intentionally small. It currently owns the shared tool +schema primitives that no longer need to live in `core/src/tools/spec.rs`: - `JsonSchema` - `AdditionalProperties` - `parse_tool_input_schema()` +- `ParsedMcpTool` +- `parse_mcp_tool()` +- `mcp_call_tool_result_output_schema()` That extraction is the first step in a longer migration. The goal is not to move all of `core/src/tools` into this crate in one shot. Instead, the plan is diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 90d3af10c8..e719d3e23f 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -1,7 +1,11 @@ //! Shared tool-schema parsing primitives that can live outside `codex-core`. mod json_schema; +mod mcp_tool; pub use json_schema::AdditionalProperties; pub use json_schema::JsonSchema; pub use json_schema::parse_tool_input_schema; +pub use mcp_tool::ParsedMcpTool; +pub use mcp_tool::mcp_call_tool_result_output_schema; +pub use mcp_tool::parse_mcp_tool; diff --git a/codex-rs/tools/src/mcp_tool.rs b/codex-rs/tools/src/mcp_tool.rs new file mode 100644 index 0000000000..99294a0e67 --- /dev/null +++ b/codex-rs/tools/src/mcp_tool.rs @@ -0,0 +1,65 @@ +use crate::JsonSchema; +use crate::parse_tool_input_schema; +use serde_json::Value as JsonValue; +use serde_json::json; + +/// Parsed MCP tool metadata and schemas that can be adapted into a higher-level +/// tool spec by downstream crates. +#[derive(Debug, PartialEq)] +pub struct ParsedMcpTool { + pub description: String, + pub input_schema: JsonSchema, + pub output_schema: JsonValue, +} + +pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result { + let mut serialized_input_schema = serde_json::Value::Object(tool.input_schema.as_ref().clone()); + + // OpenAI models mandate the "properties" field in the schema. Some MCP + // servers omit it (or set it to null), so we insert an empty object to + // match the behavior of the Agents SDK. + if let serde_json::Value::Object(obj) = &mut serialized_input_schema + && obj.get("properties").is_none_or(serde_json::Value::is_null) + { + obj.insert( + "properties".to_string(), + serde_json::Value::Object(serde_json::Map::new()), + ); + } + + let input_schema = parse_tool_input_schema(&serialized_input_schema)?; + let structured_content_schema = tool + .output_schema + .as_ref() + .map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone())) + .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())); + + Ok(ParsedMcpTool { + description: tool.description.clone().map(Into::into).unwrap_or_default(), + input_schema, + output_schema: mcp_call_tool_result_output_schema(structured_content_schema), + }) +} + +pub fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue { + json!({ + "type": "object", + "properties": { + "content": { + "type": "array", + "items": {} + }, + "structuredContent": structured_content_schema, + "isError": { + "type": "boolean" + }, + "_meta": {} + }, + "required": ["content"], + "additionalProperties": false + }) +} + +#[cfg(test)] +#[path = "mcp_tool_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/mcp_tool_tests.rs b/codex-rs/tools/src/mcp_tool_tests.rs new file mode 100644 index 0000000000..2d4df6f362 --- /dev/null +++ b/codex-rs/tools/src/mcp_tool_tests.rs @@ -0,0 +1,120 @@ +use super::ParsedMcpTool; +use super::mcp_call_tool_result_output_schema; +use super::parse_mcp_tool; +use crate::JsonSchema; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { + rmcp::model::Tool { + name: name.to_string().into(), + title: None, + description: Some(description.to_string().into()), + input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + } +} + +#[test] +fn parse_mcp_tool_inserts_empty_properties() { + let tool = mcp_tool( + "no_props", + "No properties", + serde_json::json!({ + "type": "object" + }), + ); + + assert_eq!( + parse_mcp_tool(&tool).expect("parse MCP tool"), + ParsedMcpTool { + description: "No properties".to_string(), + input_schema: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + output_schema: mcp_call_tool_result_output_schema(serde_json::json!({})), + } + ); +} + +#[test] +fn parse_mcp_tool_preserves_top_level_output_schema() { + let mut tool = mcp_tool( + "with_output", + "Has output schema", + serde_json::json!({ + "type": "object" + }), + ); + tool.output_schema = Some(std::sync::Arc::new(rmcp::model::object( + serde_json::json!({ + "properties": { + "result": { + "properties": { + "nested": {} + } + } + }, + "required": ["result"] + }), + ))); + + assert_eq!( + parse_mcp_tool(&tool).expect("parse MCP tool"), + ParsedMcpTool { + description: "Has output schema".to_string(), + input_schema: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + output_schema: mcp_call_tool_result_output_schema(serde_json::json!({ + "properties": { + "result": { + "properties": { + "nested": {} + } + } + }, + "required": ["result"] + })), + } + ); +} + +#[test] +fn parse_mcp_tool_preserves_output_schema_without_inferred_type() { + let mut tool = mcp_tool( + "with_enum_output", + "Has enum output schema", + serde_json::json!({ + "type": "object" + }), + ); + tool.output_schema = Some(std::sync::Arc::new(rmcp::model::object( + serde_json::json!({ + "enum": ["ok", "error"] + }), + ))); + + assert_eq!( + parse_mcp_tool(&tool).expect("parse MCP tool"), + ParsedMcpTool { + description: "Has enum output schema".to_string(), + input_schema: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: None, + }, + output_schema: mcp_call_tool_result_output_schema(serde_json::json!({ + "enum": ["ok", "error"] + })), + } + ); +} From 41fe98b18538c60feeb779f8fad685a1b048da7b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Mar 2026 20:54:55 -0700 Subject: [PATCH 007/185] fix: increase timeout for rust-ci to 45 minutes for now (#15948) https://github.com/openai/codex/pull/15478 raised the timeout to 35 minutes for `windows-arm64` only, though I just hit 35 minutes on https://github.com/openai/codex/actions/runs/23628986591/job/68826740108?pr=15944, so let's just increase it to 45 minutes. As noted, I'm hoping that we can bring it back down once we no longer have two copies of the `tui` crate. --- .github/workflows/rust-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c203e2b742..a7e6aa4431 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -547,7 +547,10 @@ jobs: tests: name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: ${{ matrix.runner == 'windows-arm64' && 35 || 30 }} + # Perhaps we can bring this back down to 30m once we finish the cutover + # from tui_app_server/ to tui/. Incidentally, windows-arm64 was the main + # offender for exceeding the timeout. + timeout-minutes: 45 needs: changed if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} defaults: From 21a03f16718dae1ca2f810c33b50c99b5634c931 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 26 Mar 2026 21:33:25 -0700 Subject: [PATCH 008/185] [app-server-protocol] introduce generic ClientResponse for app-server-protocol (#15921) - introduces `ClientResponse` as the symmetrical typed response union to `ClientRequest` for app-server-protocol - enables scalable event stream ingestion for use cases such as analytics - no runtime behavior changes, protocol/schema plumbing only --- .../src/protocol/common.rs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f3e56f4ffe..6bd44562e0 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -120,6 +120,41 @@ macro_rules! client_request_definitions { } } + /// Typed response from the server to the client. + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(tag = "method", rename_all = "camelCase")] + pub enum ClientResponse { + $( + $(#[doc = $variant_doc])* + $(#[serde(rename = $wire)])? + $variant { + #[serde(rename = "id")] + request_id: RequestId, + response: $response, + }, + )* + } + + impl ClientResponse { + pub fn id(&self) -> &RequestId { + match self { + $(Self::$variant { request_id, .. } => request_id,)* + } + } + + pub fn method(&self) -> String { + serde_json::to_value(self) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "".to_string()) + } + } + impl crate::experimental_api::ExperimentalApi for ClientRequest { fn experimental_reason(&self) -> Option<&'static str> { match self { @@ -1265,6 +1300,84 @@ mod tests { Ok(()) } + #[test] + fn serialize_client_response() -> Result<()> { + let response = ClientResponse::ThreadStart { + request_id: RequestId::Integer(7), + response: v2::ThreadStartResponse { + thread: v2::Thread { + id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + preview: "first prompt".to_string(), + ephemeral: true, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 2, + status: v2::ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source: v2::SessionSource::Exec, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + }, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: PathBuf::from("/tmp"), + approval_policy: v2::AskForApproval::OnFailure, + approvals_reviewer: v2::ApprovalsReviewer::User, + sandbox: v2::SandboxPolicy::DangerFullAccess, + reasoning_effort: None, + }, + }; + + assert_eq!(response.id(), &RequestId::Integer(7)); + assert_eq!(response.method(), "thread/start"); + assert_eq!( + json!({ + "method": "thread/start", + "id": 7, + "response": { + "thread": { + "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "preview": "first prompt", + "ephemeral": true, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 2, + "status": { + "type": "idle" + }, + "path": null, + "cwd": "/tmp", + "cliVersion": "0.0.0", + "source": "exec", + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5", + "modelProvider": "openai", + "serviceTier": null, + "cwd": "/tmp", + "approvalPolicy": "on-failure", + "approvalsReviewer": "user", + "sandbox": { + "type": "dangerFullAccess" + }, + "reasoningEffort": null + } + }), + serde_json::to_value(&response)?, + ); + Ok(()) + } + #[test] fn serialize_config_requirements_read() -> Result<()> { let request = ClientRequest::ConfigRequirementsRead { From dd30c8eedd171d2dda71c43fac27dc42f457da5f Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 26 Mar 2026 23:17:59 -0700 Subject: [PATCH 009/185] chore: refactor network permissions to use explicit domain and unix socket rule maps (#15120) ## Summary This PR replaces the legacy network allow/deny list model with explicit rule maps for domains and unix sockets across managed requirements, permissions profiles, the network proxy config, and the app server protocol. Concretely, it: - introduces typed domain (`allow` / `deny`) and unix socket permission (`allow` / `none`) entries instead of separate `allowed_domains`, `denied_domains`, and `allow_unix_sockets` lists - updates config loading, managed requirements merging, and exec-policy overlays to read and upsert rule entries consistently - exposes the new shape through protocol/schema outputs, debug surfaces, and app-server config APIs - rejects the legacy list-based keys and updates docs/tests to reflect the new config format ## Why The previous representation split related network policy across multiple parallel lists, which made merging and overriding rules harder to reason about. Moving to explicit keyed permission maps gives us a single source of truth per host/socket entry, makes allow/deny precedence clearer, and gives protocol consumers access to the full rule state instead of derived projections only. ## Backward Compatibility ### Backward compatible - Managed requirements still accept the legacy `experimental_network.allowed_domains`, `experimental_network.denied_domains`, and `experimental_network.allow_unix_sockets` fields. They are normalized into the new canonical `domains` and `unix_sockets` maps internally. - App-server v2 still deserializes legacy `allowedDomains`, `deniedDomains`, and `allowUnixSockets` payloads, so older clients can continue reading managed network requirements. - App-server v2 responses still populate `allowedDomains`, `deniedDomains`, and `allowUnixSockets` as legacy compatibility views derived from the canonical maps. - `managed_allowed_domains_only` keeps the same behavior after normalization. Legacy managed allowlists still participate in the same enforcement path as canonical `domains` entries. ### Not backward compatible - Permissions profiles under `[permissions..network]` no longer accept the legacy list-based keys. Those configs must use the canonical `[domains]` and `[unix_sockets]` tables instead of `allowed_domains`, `denied_domains`, or `allow_unix_sockets`. - Managed `experimental_network` config cannot mix canonical and legacy forms in the same block. For example, `domains` cannot be combined with `allowed_domains` or `denied_domains`, and `unix_sockets` cannot be combined with `allow_unix_sockets`. - The canonical format can express explicit `"none"` entries for unix sockets, but those entries do not round-trip through the legacy compatibility fields because the legacy fields only represent allow/deny lists. ## Testing `/target/debug/codex sandbox macos --log-denials /bin/zsh -c 'curl https://www.example.com' ` gives 200 with config ``` [permissions.workspace.network.domains] "www.example.com" = "allow" ``` and fails when set to deny: `curl: (56) CONNECT tunnel failed, response 403`. Also tested backward compatibility path by verifying that adding the following to `/etc/codex/requirements.toml` works: ``` [experimental_network] allowed_domains = ["www.example.com"] ``` --- .../codex_app_server_protocol.schemas.json | 44 ++ .../codex_app_server_protocol.v2.schemas.json | 44 ++ .../v2/ConfigRequirementsReadResponse.json | 44 ++ .../typescript/v2/NetworkDomainPermission.ts | 5 + .../typescript/v2/NetworkRequirements.ts | 29 +- .../v2/NetworkUnixSocketPermission.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../app-server-protocol/src/protocol/v2.rs | 114 +++++ codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/config_api.rs | 157 ++++++- codex-rs/config/src/config_requirements.rs | 428 +++++++++++++++++- codex-rs/config/src/lib.rs | 4 + codex-rs/core/config.schema.json | 42 +- codex-rs/core/src/codex.rs | 12 +- codex-rs/core/src/codex_tests.rs | 31 +- codex-rs/core/src/config/config_tests.rs | 21 +- codex-rs/core/src/config/mod.rs | 5 + .../core/src/config/network_proxy_spec.rs | 94 ++-- .../src/config/network_proxy_spec_tests.rs | 287 ++++++++++-- codex-rs/core/src/config/permissions.rs | 137 +++++- codex-rs/core/src/config/permissions_tests.rs | 129 ++++++ codex-rs/core/src/config_loader/mod.rs | 4 + codex-rs/core/src/environment_context.rs | 12 +- codex-rs/core/src/guardian/tests.rs | 17 +- codex-rs/core/src/network_proxy_loader.rs | 40 +- .../core/src/network_proxy_loader_tests.rs | 168 ++++++- codex-rs/network-proxy/README.md | 33 +- codex-rs/network-proxy/src/config.rs | 301 +++++++++++- codex-rs/network-proxy/src/http_proxy.rs | 39 +- codex-rs/network-proxy/src/lib.rs | 5 + codex-rs/network-proxy/src/mitm_tests.rs | 23 +- codex-rs/network-proxy/src/network_policy.rs | 18 +- codex-rs/network-proxy/src/proxy.rs | 2 +- codex-rs/network-proxy/src/runtime.rs | 352 +++++++------- codex-rs/network-proxy/src/state.rs | 90 ++-- codex-rs/tui/src/debug_config.rs | 64 ++- codex-rs/tui_app_server/src/debug_config.rs | 101 ++++- 37 files changed, 2413 insertions(+), 492 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f52faf5b15..8531542d55 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9203,6 +9203,13 @@ ], "type": "string" }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -9212,6 +9219,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -9227,6 +9235,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -9248,6 +9257,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -9256,6 +9266,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -9270,6 +9290,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -9277,10 +9304,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5d053604f5..9d28f531af 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6017,6 +6017,13 @@ ], "type": "string" }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -6026,6 +6033,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -6041,6 +6049,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -6062,6 +6071,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -6070,6 +6080,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -6084,6 +6104,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -6091,10 +6118,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index e0c8304c1f..de5eca1d30 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -102,6 +102,13 @@ }, "type": "object" }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -111,6 +118,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -126,6 +134,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -147,6 +156,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -155,6 +165,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -169,6 +189,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -176,10 +203,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "ResidencyRequirement": { "enum": [ "us" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts new file mode 100644 index 0000000000..2ea44392de --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts @@ -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 NetworkDomainPermission = "allow" | "deny"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index 1f1653c277..5fc942bef5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -1,5 +1,32 @@ // 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 { NetworkDomainPermission } from "./NetworkDomainPermission"; +import type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; -export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, +/** + * Canonical network permission map for `experimental_network`. + */ +domains: { [key in string]?: NetworkDomainPermission } | null, +/** + * When true, only managed allowlist entries are respected while managed + * network enforcement is active. + */ +managedAllowedDomainsOnly: boolean | null, +/** + * Legacy compatibility view derived from `domains`. + */ +allowedDomains: Array | null, +/** + * Legacy compatibility view derived from `domains`. + */ +deniedDomains: Array | null, +/** + * Canonical unix socket permission map for `experimental_network`. + */ +unixSockets: { [key in string]?: NetworkUnixSocketPermission } | null, +/** + * Legacy compatibility view derived from `unix_sockets`. + */ +allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts new file mode 100644 index 0000000000..466c6e5f8f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts @@ -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 NetworkUnixSocketPermission = "allow" | "none"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 73fe05eaeb..d0687e5f1d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -197,9 +197,11 @@ export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; +export type { NetworkDomainPermission } from "./NetworkDomainPermission"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2211e7aa15..930112eed9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -866,12 +866,38 @@ pub struct NetworkRequirements { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, + /// Canonical network permission map for `experimental_network`. + pub domains: Option>, + /// When true, only managed allowlist entries are respected while managed + /// network enforcement is active. + pub managed_allowed_domains_only: Option, + /// Legacy compatibility view derived from `domains`. pub allowed_domains: Option>, + /// Legacy compatibility view derived from `domains`. pub denied_domains: Option>, + /// Canonical unix socket permission map for `experimental_network`. + pub unix_sockets: Option>, + /// Legacy compatibility view derived from `unix_sockets`. pub allow_unix_sockets: Option>, pub allow_local_binding: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkDomainPermission { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkUnixSocketPermission { + Allow, + None, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -7487,6 +7513,94 @@ mod tests { ); } + #[test] + fn network_requirements_deserializes_legacy_fields() { + let requirements: NetworkRequirements = serde_json::from_value(json!({ + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "allowUnixSockets": ["/tmp/proxy.sock"] + })) + .expect("legacy network requirements should deserialize"); + + assert_eq!( + requirements, + NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: None, + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: None, + } + ); + } + + #[test] + fn network_requirements_serializes_canonical_and_legacy_fields() { + let requirements = NetworkRequirements { + enabled: Some(true), + http_port: Some(8080), + socks_port: Some(1080), + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(true), + domains: Some(BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow), + ( + "blocked.example.com".to_string(), + NetworkDomainPermission::Deny, + ), + ])), + managed_allowed_domains_only: Some(true), + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: Some(BTreeMap::from([ + ( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + ), + ])), + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: Some(true), + }; + + assert_eq!( + serde_json::to_value(requirements).expect("network requirements should serialize"), + json!({ + "enabled": true, + "httpPort": 8080, + "socksPort": 1080, + "allowUpstreamProxy": false, + "dangerouslyAllowNonLoopbackProxy": false, + "dangerouslyAllowAllUnixSockets": true, + "domains": { + "api.openai.com": "allow", + "blocked.example.com": "deny" + }, + "managedAllowedDomainsOnly": true, + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "unixSockets": { + "/tmp/ignored.sock": "none", + "/tmp/proxy.sock": "allow" + }, + "allowUnixSockets": ["/tmp/proxy.sock"], + "allowLocalBinding": true + }) + ); + } + #[test] fn core_turn_item_into_thread_item_converts_supported_variants() { let user_item = TurnItem::UserMessage(UserMessageItem { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cabea022a1..38cdacac79 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -194,7 +194,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). - `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`), `enforceResidency`, and `network` constraints. +- `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`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 7f6acc54d1..d138d2f5eb 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -12,7 +12,9 @@ use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NetworkDomainPermission; use codex_app_server_protocol::NetworkRequirements; +use codex_app_server_protocol::NetworkUnixSocketPermission; use codex_app_server_protocol::SandboxMode; use codex_core::AnalyticsEventsClient; use codex_core::ThreadManager; @@ -410,6 +412,20 @@ fn map_residency_requirement_to_api( fn map_network_requirements_to_api( network: codex_core::config_loader::NetworkRequirementsToml, ) -> NetworkRequirements { + let allowed_domains = network + .domains + .as_ref() + .and_then(codex_core::config_loader::NetworkDomainPermissionsToml::allowed_domains); + let denied_domains = network + .domains + .as_ref() + .and_then(codex_core::config_loader::NetworkDomainPermissionsToml::denied_domains); + let allow_unix_sockets = network + .unix_sockets + .as_ref() + .map(codex_core::config_loader::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .filter(|entries| !entries.is_empty()); + NetworkRequirements { enabled: network.enabled, http_port: network.http_port, @@ -417,13 +433,58 @@ fn map_network_requirements_to_api( allow_upstream_proxy: network.allow_upstream_proxy, dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, - allowed_domains: network.allowed_domains, - denied_domains: network.denied_domains, - allow_unix_sockets: network.allow_unix_sockets, + domains: network.domains.map(|domains| { + domains + .entries + .into_iter() + .map(|(pattern, permission)| { + (pattern, map_network_domain_permission_to_api(permission)) + }) + .collect() + }), + managed_allowed_domains_only: network.managed_allowed_domains_only, + allowed_domains, + denied_domains, + unix_sockets: network.unix_sockets.map(|unix_sockets| { + unix_sockets + .entries + .into_iter() + .map(|(path, permission)| { + (path, map_network_unix_socket_permission_to_api(permission)) + }) + .collect() + }), + allow_unix_sockets, allow_local_binding: network.allow_local_binding, } } +fn map_network_domain_permission_to_api( + permission: codex_core::config_loader::NetworkDomainPermissionToml, +) -> NetworkDomainPermission { + match permission { + codex_core::config_loader::NetworkDomainPermissionToml::Allow => { + NetworkDomainPermission::Allow + } + codex_core::config_loader::NetworkDomainPermissionToml::Deny => { + NetworkDomainPermission::Deny + } + } +} + +fn map_network_unix_socket_permission_to_api( + permission: codex_core::config_loader::NetworkUnixSocketPermissionToml, +) -> NetworkUnixSocketPermission { + match permission { + codex_core::config_loader::NetworkUnixSocketPermissionToml::Allow => { + NetworkUnixSocketPermission::Allow + } + codex_core::config_loader::NetworkUnixSocketPermissionToml::None => { + NetworkUnixSocketPermission::None + } + } +} + fn map_error(err: ConfigServiceError) -> JSONRPCErrorError { if let Some(code) = err.write_error_code() { return config_write_error(code, err.to_string()); @@ -452,7 +513,11 @@ mod tests { use codex_core::AnalyticsEventsClient; use codex_core::AuthManager; use codex_core::CodexAuth; + use codex_core::config_loader::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml; + use codex_core::config_loader::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml; use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; + use codex_core::config_loader::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml; + use codex_core::config_loader::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml; use codex_features::Feature; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use pretty_assertions::assert_eq; @@ -505,10 +570,25 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), - allowed_domains: Some(vec!["api.openai.com".to_string()]), + domains: Some(CoreNetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([ + ( + "api.openai.com".to_string(), + CoreNetworkDomainPermissionToml::Allow, + ), + ( + "example.com".to_string(), + CoreNetworkDomainPermissionToml::Deny, + ), + ]), + }), managed_allowed_domains_only: Some(false), - denied_domains: Some(vec!["example.com".to_string()]), - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { + entries: std::collections::BTreeMap::from([( + "/tmp/proxy.sock".to_string(), + CoreNetworkUnixSocketPermissionToml::Allow, + )]), + }), allow_local_binding: Some(true), }), }; @@ -550,14 +630,79 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), + domains: Some(std::collections::BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow,), + ("example.com".to_string(), NetworkDomainPermission::Deny), + ])), + managed_allowed_domains_only: Some(false), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), + unix_sockets: Some(std::collections::BTreeMap::from([( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + )])), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), allow_local_binding: Some(true), }), ); } + #[test] + fn map_requirements_toml_to_api_omits_unix_socket_none_entries_from_legacy_network_fields() { + let requirements = ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + guardian_developer_instructions: None, + feature_requirements: None, + mcp_servers: None, + apps: None, + rules: None, + enforce_residency: None, + network: Some(CoreNetworkRequirementsToml { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { + entries: std::collections::BTreeMap::from([( + "/tmp/ignored.sock".to_string(), + CoreNetworkUnixSocketPermissionToml::None, + )]), + }), + allow_local_binding: None, + }), + }; + + let mapped = map_requirements_toml_to_api(requirements); + + assert_eq!( + mapped.network, + Some(NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + allowed_domains: None, + denied_domains: None, + unix_sockets: Some(std::collections::BTreeMap::from([( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + )])), + allow_unix_sockets: None, + allow_local_binding: None, + }), + ); + } + #[test] fn map_requirements_toml_to_api_normalizes_allowed_web_search_modes() { let requirements = ConfigRequirementsToml { diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 57d762c0f1..d63fa1e8b2 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -5,6 +5,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; +use serde::de::Error as _; use std::collections::BTreeMap; use std::fmt; @@ -132,7 +133,93 @@ pub struct McpServerRequirement { pub identity: McpServerIdentity, } -#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)] pub struct NetworkRequirementsToml { pub enabled: Option, pub http_port: Option, @@ -140,17 +227,121 @@ pub struct NetworkRequirementsToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, - pub allowed_domains: Option>, + pub domains: Option, /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. pub managed_allowed_domains_only: Option, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, + pub unix_sockets: Option, pub allow_local_binding: Option, } +#[derive(Deserialize)] +struct RawNetworkRequirementsToml { + enabled: Option, + http_port: Option, + socks_port: Option, + allow_upstream_proxy: Option, + dangerously_allow_non_loopback_proxy: Option, + dangerously_allow_all_unix_sockets: Option, + domains: Option, + #[serde(default)] + allowed_domains: Option>, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + managed_allowed_domains_only: Option, + #[serde(default)] + denied_domains: Option>, + unix_sockets: Option, + #[serde(default)] + allow_unix_sockets: Option>, + allow_local_binding: Option, +} + +impl<'de> Deserialize<'de> for NetworkRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawNetworkRequirementsToml::deserialize(deserializer)?; + let RawNetworkRequirementsToml { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + allowed_domains, + managed_allowed_domains_only, + denied_domains, + unix_sockets, + allow_unix_sockets, + allow_local_binding, + } = raw; + + if domains.is_some() && (allowed_domains.is_some() || denied_domains.is_some()) { + return Err(D::Error::custom( + "`experimental_network.domains` cannot be combined with legacy `allowed_domains` or `denied_domains`", + )); + } + + if unix_sockets.is_some() && allow_unix_sockets.is_some() { + return Err(D::Error::custom( + "`experimental_network.unix_sockets` cannot be combined with legacy `allow_unix_sockets`", + )); + } + + Ok(Self { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains: domains + .or_else(|| legacy_domain_permissions_from_lists(allowed_domains, denied_domains)), + managed_allowed_domains_only, + unix_sockets: unix_sockets + .or_else(|| legacy_unix_socket_permissions_from_list(allow_unix_sockets)), + allow_local_binding, + }) + } +} + +/// Legacy list normalization is intentionally lossy: explicit empty legacy +/// lists are treated as unset when converted to the canonical network +/// permission shape. +fn legacy_domain_permissions_from_lists( + allowed_domains: Option>, + denied_domains: Option>, +) -> Option { + let mut entries = BTreeMap::new(); + + for pattern in allowed_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Allow); + } + + for pattern in denied_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Deny); + } + + (!entries.is_empty()).then_some(NetworkDomainPermissionsToml { entries }) +} + +fn legacy_unix_socket_permissions_from_list( + allow_unix_sockets: Option>, +) -> Option { + let entries = allow_unix_sockets + .unwrap_or_default() + .into_iter() + .map(|path| (path, NetworkUnixSocketPermissionToml::Allow)) + .collect::>(); + + (!entries.is_empty()).then_some(NetworkUnixSocketPermissionsToml { entries }) +} + /// Normalized network constraints derived from requirements TOML. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct NetworkConstraints { pub enabled: Option, pub http_port: Option, @@ -158,15 +349,24 @@ pub struct NetworkConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, - pub allowed_domains: Option>, + pub domains: Option, /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. pub managed_allowed_domains_only: Option, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, + pub unix_sockets: Option, pub allow_local_binding: Option, } +impl<'de> Deserialize<'de> for NetworkConstraints { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let requirements = NetworkRequirementsToml::deserialize(deserializer)?; + Ok(requirements.into()) + } +} + impl From for NetworkConstraints { fn from(value: NetworkRequirementsToml) -> Self { let NetworkRequirementsToml { @@ -176,10 +376,9 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + unix_sockets, allow_local_binding, } = value; Self { @@ -189,10 +388,9 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + unix_sockets, allow_local_binding, } } @@ -1470,6 +1668,78 @@ guardian_developer_instructions = """ #[test] fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { + let toml_str = r#" + [experimental_network] + enabled = true + allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true + managed_allowed_domains_only = true + allow_local_binding = false + + [experimental_network.domains] + "api.example.com" = "allow" + "*.openai.com" = "allow" + "blocked.example.com" = "deny" + + [experimental_network.unix_sockets] + "/tmp/example.sock" = "allow" + "#; + + let source = RequirementSource::CloudRequirements; + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let sourced_network = requirements + .network + .expect("network requirements should be preserved as constraints"); + + assert_eq!(sourced_network.source, source); + assert_eq!(sourced_network.value.enabled, Some(true)); + assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!( + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) + ); + assert_eq!( + sourced_network.value.managed_allowed_domains_only, + Some(true) + ); + assert_eq!( + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) + ); + assert_eq!(sourced_network.value.allow_local_binding, Some(false)); + + Ok(()) + } + + #[test] + fn legacy_network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { let toml_str = r#" [experimental_network] enabled = true @@ -1499,29 +1769,137 @@ guardian_developer_instructions = """ Some(true) ); assert_eq!( - sourced_network.value.allowed_domains.as_ref(), - Some(&vec![ - "api.example.com".to_string(), - "*.openai.com".to_string() - ]) + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) ); assert_eq!( sourced_network.value.managed_allowed_domains_only, Some(true) ); assert_eq!( - sourced_network.value.denied_domains.as_ref(), - Some(&vec!["blocked.example.com".to_string()]) - ); - assert_eq!( - sourced_network.value.allow_unix_sockets.as_ref(), - Some(&vec!["/tmp/example.sock".to_string()]) + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) ); assert_eq!(sourced_network.value.allow_local_binding, Some(false)); Ok(()) } + #[test] + fn mixed_legacy_and_canonical_network_requirements_are_rejected() { + let err = from_str::( + r#" + [experimental_network] + allowed_domains = ["api.example.com"] + + [experimental_network.domains] + "*.openai.com" = "allow" + "#, + ) + .expect_err("mixed network domain shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.domains` cannot be combined"), + "unexpected error: {err:#}" + ); + + let err = from_str::( + r#" + [experimental_network] + allow_unix_sockets = ["/tmp/example.sock"] + + [experimental_network.unix_sockets] + "/tmp/another.sock" = "allow" + "#, + ) + .expect_err("mixed network unix socket shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.unix_sockets` cannot be combined"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn network_permission_containers_project_allowed_and_denied_entries() { + let domains = NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }; + let unix_sockets = NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }; + + assert_eq!( + domains.allowed_domains(), + Some(vec![ + "*.openai.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + domains.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + } + .denied_domains(), + None + ); + assert_eq!( + unix_sockets.allow_unix_sockets(), + vec!["/tmp/example.sock".to_string()] + ); + } + #[test] fn deserialize_mcp_server_requirements() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 7bd572fea3..9492d56b4d 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -25,7 +25,11 @@ pub use config_requirements::FeatureRequirementsToml; pub use config_requirements::McpServerIdentity; pub use config_requirements::McpServerRequirement; pub use config_requirements::NetworkConstraints; +pub use config_requirements::NetworkDomainPermissionToml; +pub use config_requirements::NetworkDomainPermissionsToml; pub use config_requirements::NetworkRequirementsToml; +pub use config_requirements::NetworkUnixSocketPermissionToml; +pub use config_requirements::NetworkUnixSocketPermissionsToml; pub use config_requirements::RequirementSource; pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3d091006c9..78113b126d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -913,6 +913,16 @@ ], "type": "object" }, + "NetworkDomainPermissionToml": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkDomainPermissionsToml": { + "type": "object" + }, "NetworkModeSchema": { "enum": [ "limited", @@ -926,32 +936,17 @@ "allow_local_binding": { "type": "boolean" }, - "allow_unix_sockets": { - "items": { - "type": "string" - }, - "type": "array" - }, "allow_upstream_proxy": { "type": "boolean" }, - "allowed_domains": { - "items": { - "type": "string" - }, - "type": "array" - }, "dangerously_allow_all_unix_sockets": { "type": "boolean" }, "dangerously_allow_non_loopback_proxy": { "type": "boolean" }, - "denied_domains": { - "items": { - "type": "string" - }, - "type": "array" + "domains": { + "$ref": "#/definitions/NetworkDomainPermissionsToml" }, "enable_socks5": { "type": "boolean" @@ -970,10 +965,23 @@ }, "socks_url": { "type": "string" + }, + "unix_sockets": { + "$ref": "#/definitions/NetworkUnixSocketPermissionsToml" } }, "type": "object" }, + "NetworkUnixSocketPermissionToml": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, + "NetworkUnixSocketPermissionsToml": { + "type": "object" + }, "Notice": { "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", "properties": { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ed24d28466..54f5c93f2d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1029,8 +1029,16 @@ impl TurnContext { .network .as_ref()?; Some(TurnContextNetworkItem { - allowed_domains: network.allowed_domains.clone().unwrap_or_default(), - denied_domains: network.denied_domains.clone().unwrap_or_default(), + allowed_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + denied_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) + .unwrap_or_default(), }) } } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5a556f2071..ff0e08e211 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -5,6 +5,8 @@ use crate::config::test_config; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::NetworkConstraints; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; use crate::exec::ExecCapturePolicy; @@ -471,8 +473,8 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho let current_cfg = started_proxy.proxy().current_cfg().await?; assert_eq!( - current_cfg.network.allowed_domains, - vec!["example.com".to_string()] + current_cfg.network.allowed_domains(), + Some(vec!["example.com".to_string()]) ); Ok(()) } @@ -483,7 +485,12 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { - allowed_domains: Some(vec!["managed.example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([( + "managed.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), managed_allowed_domains_only: Some(true), ..Default::default() }), @@ -510,8 +517,8 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let current_cfg = started_proxy.proxy().current_cfg().await?; assert_eq!( - current_cfg.network.allowed_domains, - vec!["managed.example.com".to_string()] + current_cfg.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) ); Ok(()) } @@ -3687,8 +3694,18 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( let mut requirements = config.config_layer_stack.requirements().clone(); requirements.network = Some(Sourced::new( NetworkConstraints { - allowed_domains: Some(vec!["api.example.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([ + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }), ..Default::default() }, RequirementSource::CloudRequirements, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 4d6527ab80..60f0e06f7d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -306,7 +306,9 @@ enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false -allowed_domains = ["openai.com"] + +[permissions.workspace.network.domains] +"openai.com" = "allow" "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); @@ -343,9 +345,13 @@ allowed_domains = ["openai.com"] dangerously_allow_non_loopback_proxy: None, dangerously_allow_all_unix_sockets: None, mode: None, - allowed_domains: Some(vec!["openai.com".to_string()]), - denied_domains: None, - allow_unix_sockets: None, + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), + unix_sockets: None, allow_local_binding: None, }), }, @@ -421,7 +427,12 @@ fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> st )]), }), network: Some(NetworkToml { - allowed_domains: Some(vec!["openai.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }), }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fa21cbad84..1a0722119b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -123,9 +123,14 @@ pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub use permissions::FilesystemPermissionToml; pub use permissions::FilesystemPermissionsToml; +pub use permissions::NetworkDomainPermissionToml; +pub use permissions::NetworkDomainPermissionsToml; pub use permissions::NetworkToml; +pub use permissions::NetworkUnixSocketPermissionToml; +pub use permissions::NetworkUnixSocketPermissionsToml; pub use permissions::PermissionProfileToml; pub use permissions::PermissionsToml; +pub(crate) use permissions::overlay_network_domain_permissions; pub(crate) use permissions::resolve_permission_profile; pub use service::ConfigService; pub use service::ConfigServiceError; diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 386100d34b..93b59cf5f5 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -226,33 +226,63 @@ impl NetworkProxySpec { Some(dangerously_allow_all_unix_sockets); } let managed_allowed_domains = if hard_deny_allowlist_misses { - Some(requirements.allowed_domains.clone().unwrap_or_default()) + Some( + requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + ) } else { - requirements.allowed_domains.clone() + requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) }; - if let Some(allowed_domains) = managed_allowed_domains { + if let Some(managed_allowed_domains) = managed_allowed_domains { // Managed requirements seed the baseline allowlist. User additions // can extend that baseline unless managed-only mode pins the // effective allowlist to the managed set. - config.network.allowed_domains = if allowlist_expansion_enabled { - Self::merge_domain_lists(allowed_domains.clone(), &config.network.allowed_domains) + let effective_allowed_domains = if allowlist_expansion_enabled { + Self::merge_domain_lists( + managed_allowed_domains.clone(), + config.network.allowed_domains().as_deref().unwrap_or(&[]), + ) } else { - allowed_domains.clone() + managed_allowed_domains.clone() }; - constraints.allowed_domains = Some(allowed_domains); + config + .network + .set_allowed_domains(effective_allowed_domains); + constraints.allowed_domains = Some(managed_allowed_domains); constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled); } - if let Some(denied_domains) = requirements.denied_domains.clone() { - config.network.denied_domains = if denylist_expansion_enabled { - Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains) + let managed_denied_domains = requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains); + if let Some(managed_denied_domains) = managed_denied_domains { + let effective_denied_domains = if denylist_expansion_enabled { + Self::merge_domain_lists( + managed_denied_domains.clone(), + config.network.denied_domains().as_deref().unwrap_or(&[]), + ) } else { - denied_domains.clone() + managed_denied_domains.clone() }; - constraints.denied_domains = Some(denied_domains); + config.network.set_denied_domains(effective_denied_domains); + constraints.denied_domains = Some(managed_denied_domains); constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled); } - if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() { - config.network.allow_unix_sockets = allow_unix_sockets.clone(); + if requirements.unix_sockets.is_some() { + let allow_unix_sockets = requirements + .unix_sockets + .as_ref() + .map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .unwrap_or_default(); + config + .network + .set_allow_unix_sockets(allow_unix_sockets.clone()); constraints.allow_unix_sockets = Some(allow_unix_sockets); } if let Some(allow_local_binding) = requirements.allow_local_binding { @@ -299,37 +329,25 @@ impl NetworkProxySpec { fn apply_exec_policy_network_rules(config: &mut NetworkProxyConfig, exec_policy: &Policy) { let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains(); - upsert_network_domains( - &mut config.network.allowed_domains, - &mut config.network.denied_domains, - allowed_domains, - ); - upsert_network_domains( - &mut config.network.denied_domains, - &mut config.network.allowed_domains, - denied_domains, - ); + upsert_network_domains(config, allowed_domains, /*allow*/ true); + upsert_network_domains(config, denied_domains, /*allow*/ false); } -fn upsert_network_domains( - target: &mut Vec, - opposite: &mut Vec, - hosts: Vec, -) { +fn upsert_network_domains(config: &mut NetworkProxyConfig, hosts: Vec, allow: bool) { let mut incoming = HashSet::new(); - let mut deduped_hosts = Vec::new(); for host in hosts { if incoming.insert(host.clone()) { - deduped_hosts.push(host); + config.network.upsert_domain_permission( + host, + if allow { + codex_network_proxy::NetworkDomainPermission::Allow + } else { + codex_network_proxy::NetworkDomainPermission::Deny + }, + normalize_host, + ); } } - if incoming.is_empty() { - return; - } - - opposite.retain(|entry| !incoming.contains(&normalize_host(entry))); - target.retain(|entry| !incoming.contains(&normalize_host(entry))); - target.extend(deduped_hosts); } #[cfg(test)] diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 4c6e82358e..77007885a4 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -1,6 +1,20 @@ use super::*; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; +use codex_network_proxy::NetworkDomainPermission; use pretty_assertions::assert_eq; +fn domain_permissions( + entries: impl IntoIterator, +) -> NetworkDomainPermissionsToml { + NetworkDomainPermissionsToml { + entries: entries + .into_iter() + .map(|(pattern, permission)| (pattern.to_string(), permission)) + .collect(), + } +} + #[test] fn build_state_with_audit_metadata_threads_metadata_to_state() { let spec = NetworkProxySpec { @@ -24,9 +38,14 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() { #[test] fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), ..Default::default() }; @@ -38,8 +57,11 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { .expect("config should stay within the managed allowlist"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string(), "api.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec![ + "*.example.com".to_string(), + "api.example.com".to_string() + ]) ); assert_eq!( spec.constraints.allowed_domains, @@ -48,14 +70,92 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); } +#[test] +fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "api.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed allowlist should not erase a user deny"); + + assert_eq!(spec.config.network.allowed_domains(), None); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["api.example.com".to_string()]) + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["api.example.com".to_string()]) + ); +} + +#[test] +fn requirements_allowlist_expansion_keeps_user_entries_mutable() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still allow user edits"); + + let mut candidate = spec.config.clone(); + candidate.network.upsert_domain_permission( + "api.example.com".to_string(), + NetworkDomainPermission::Deny, + normalize_host, + ); + + assert_eq!( + candidate.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) + ); + assert_eq!( + candidate.network.denied_domains(), + Some(vec!["api.example.com".to_string()]) + ); + validate_policy_against_constraints(&candidate, &spec.constraints) + .expect("user allowlist entries should not become managed constraints"); +} + #[test] fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["evil.com".to_string()]; - config.network.denied_domains = vec!["more-blocked.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["evil.com".to_string()]); + config + .network + .set_denied_domains(vec!["more-blocked.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), + domains: Some(domain_permissions([ + ("*.example.com", NetworkDomainPermissionToml::Allow), + ("blocked.example.com", NetworkDomainPermissionToml::Deny), + ])), ..Default::default() }; @@ -67,12 +167,12 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { .expect("yolo mode should pin the effective policy to the managed baseline"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) ); assert_eq!( - spec.config.network.denied_domains, - vec!["blocked.example.com".to_string()] + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) ); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); @@ -81,9 +181,14 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { #[test] fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), managed_allowed_domains_only: Some(true), ..Default::default() }; @@ -96,8 +201,8 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { .expect("managed baseline should still load"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) ); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); } @@ -105,9 +210,14 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { #[test] fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["managed.example.com".to_string()]), + domains: Some(domain_permissions([( + "managed.example.com", + NetworkDomainPermissionToml::Allow, + )])), managed_allowed_domains_only: Some(true), ..Default::default() }; @@ -120,8 +230,8 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() .expect("managed-only allowlist should still load"); assert_eq!( - spec.config.network.allowed_domains, - vec!["managed.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) ); assert_eq!( spec.constraints.allowed_domains, @@ -134,7 +244,9 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() #[test] fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { managed_allowed_domains_only: Some(true), ..Default::default() @@ -147,7 +259,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain ) .expect("managed-only mode should treat missing managed allowlist as empty"); - assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.config.network.allowed_domains(), None); assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert!(spec.hard_deny_allowlist_misses); @@ -156,7 +268,9 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain #[test] fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { managed_allowed_domains_only: Some(true), ..Default::default() @@ -169,18 +283,89 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m ) .expect("managed-only mode should treat missing managed allowlist as empty"); - assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.config.network.allowed_domains(), None); assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert!(spec.hard_deny_allowlist_misses); } +#[test] +fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("deny-only requirements should not constrain the allowlist"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["api.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowed_domains, None); + assert_eq!(spec.constraints.allowlist_expansion_enabled, None); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["managed-blocked.example.com".to_string()]) + ); +} + +#[test] +fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("allow-only requirements should not constrain the denylist"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) + ); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!(spec.constraints.denied_domains, None); + assert_eq!(spec.constraints.denylist_expansion_enabled, None); +} + #[test] fn requirements_denied_domains_are_a_baseline_for_default_mode() { let mut config = NetworkProxyConfig::default(); - config.network.denied_domains = vec!["blocked.example.com".to_string()]; + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); let requirements = NetworkConstraints { - denied_domains: Some(vec!["managed-blocked.example.com".to_string()]), + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), ..Default::default() }; @@ -192,11 +377,55 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() { .expect("default mode should merge managed and user deny entries"); assert_eq!( - spec.config.network.denied_domains, - vec![ + spec.config.network.denied_domains(), + Some(vec![ "managed-blocked.example.com".to_string(), "blocked.example.com".to_string() - ] + ]) + ); + assert_eq!( + spec.constraints.denied_domains, + Some(vec!["managed-blocked.example.com".to_string()]) ); assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); } + +#[test] +fn requirements_denylist_expansion_keeps_user_entries_mutable() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still allow user edits"); + + let mut candidate = spec.config.clone(); + candidate.network.upsert_domain_permission( + "blocked.example.com".to_string(), + NetworkDomainPermission::Allow, + normalize_host, + ); + + assert_eq!( + candidate.network.allowed_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + candidate.network.denied_domains(), + Some(vec!["managed-blocked.example.com".to_string()]) + ); + validate_policy_against_constraints(&candidate, &spec.constraints) + .expect("user denylist entries should not become managed constraints"); +} diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 759c269b76..73dad1c73c 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -5,8 +5,11 @@ use std::path::Component; use std::path::Path; use std::path::PathBuf; +use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; +use codex_network_proxy::normalize_host; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -56,6 +59,98 @@ pub enum FilesystemPermissionToml { Scoped(BTreeMap), } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + #[cfg(test)] + pub(crate) fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + #[cfg(test)] + pub(crate) fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub(crate) fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct NetworkToml { @@ -69,9 +164,8 @@ pub struct NetworkToml { pub dangerously_allow_all_unix_sockets: Option, #[schemars(with = "Option")] pub mode: Option, - pub allowed_domains: Option>, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, + pub domains: Option, + pub unix_sockets: Option, pub allow_local_binding: Option, } @@ -114,14 +208,22 @@ impl NetworkToml { if let Some(mode) = self.mode { config.network.mode = mode; } - if let Some(allowed_domains) = self.allowed_domains.as_ref() { - config.network.allowed_domains = allowed_domains.clone(); + if let Some(domains) = self.domains.as_ref() { + overlay_network_domain_permissions(config, domains); } - if let Some(denied_domains) = self.denied_domains.as_ref() { - config.network.denied_domains = denied_domains.clone(); - } - if let Some(allow_unix_sockets) = self.allow_unix_sockets.as_ref() { - config.network.allow_unix_sockets = allow_unix_sockets.clone(); + if let Some(unix_sockets) = self.unix_sockets.as_ref() { + let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default(); + for (path, permission) in &unix_sockets.entries { + let permission = match permission { + NetworkUnixSocketPermissionToml::Allow => { + ProxyNetworkUnixSocketPermission::Allow + } + NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None, + }; + proxy_unix_sockets.entries.insert(path.clone(), permission); + } + config.network.unix_sockets = + (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets); } if let Some(allow_local_binding) = self.allow_local_binding { config.network.allow_local_binding = allow_local_binding; @@ -135,6 +237,21 @@ impl NetworkToml { } } +pub(crate) fn overlay_network_domain_permissions( + config: &mut NetworkProxyConfig, + domains: &NetworkDomainPermissionsToml, +) { + for (pattern, permission) in &domains.entries { + let permission = match permission { + NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow, + NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny, + }; + config + .network + .upsert_domain_permission(pattern.clone(), permission, normalize_host); + } +} + pub(crate) fn network_proxy_config_from_profile_network( network: Option<&NetworkToml>, ) -> NetworkProxyConfig { diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index b90b903e88..b1313d1b0e 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -76,3 +76,132 @@ fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> Ok(()) } + +#[test] +fn network_toml_ignores_legacy_network_list_keys() { + let parsed = toml::from_str::( + r#" +allowed_domains = ["openai.com"] +"#, + ) + .expect("legacy network list keys should be ignored"); + + assert_eq!(parsed, NetworkToml::default()); +} + +#[test] +fn network_permission_containers_project_allowed_and_denied_entries() { + let domains = NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }; + let unix_sockets = NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }; + + assert_eq!( + domains.allowed_domains(), + Some(vec![ + "*.openai.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + domains.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + } + .denied_domains(), + None + ); + assert_eq!( + unix_sockets.allow_unix_sockets(), + vec!["/tmp/example.sock".to_string()] + ); +} + +#[test] +fn network_toml_overlays_unix_socket_permissions_by_path() { + let mut config = NetworkProxyConfig::default(); + + NetworkToml { + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/base.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/override.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ]), + }), + ..Default::default() + } + .apply_to_network_proxy_config(&mut config); + + NetworkToml { + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/extra.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/override.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }), + ..Default::default() + } + .apply_to_network_proxy_config(&mut config); + + assert_eq!( + config.network.unix_sockets, + Some(codex_network_proxy::NetworkUnixSocketPermissions { + entries: BTreeMap::from([ + ( + "/tmp/base.sock".to_string(), + ProxyNetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/extra.sock".to_string(), + ProxyNetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/override.sock".to_string(), + ProxyNetworkUnixSocketPermission::None, + ), + ]), + }) + ); +} diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index e4c5039588..df33665956 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -42,7 +42,11 @@ pub use codex_config::LoaderOverrides; pub use codex_config::McpServerIdentity; pub use codex_config::McpServerRequirement; pub use codex_config::NetworkConstraints; +pub use codex_config::NetworkDomainPermissionToml; +pub use codex_config::NetworkDomainPermissionsToml; pub use codex_config::NetworkRequirementsToml; +pub use codex_config::NetworkUnixSocketPermissionToml; +pub use codex_config::NetworkUnixSocketPermissionsToml; pub use codex_config::RequirementSource; pub use codex_config::ResidencyRequirement; pub use codex_config::SandboxModeRequirement; diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index bbb39da113..df4e49cf4e 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -130,8 +130,16 @@ impl EnvironmentContext { .as_ref()?; Some(NetworkContext { - allowed_domains: network.allowed_domains.clone().unwrap_or_default(), - denied_domains: network.denied_domains.clone().unwrap_or_default(), + allowed_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + denied_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) + .unwrap_or_default(), }) } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 3e6d409e76..b71b48bd62 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -11,6 +11,8 @@ use crate::config::test_config; use crate::config_loader::ConfigLayerStack; use crate::config_loader::FeatureRequirementsToml; use crate::config_loader::NetworkConstraints; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; use crate::protocol::SandboxPolicy; @@ -971,7 +973,12 @@ fn guardian_review_session_config_preserves_parent_network_proxy() { NetworkProxyConfig::default(), Some(NetworkConstraints { enabled: Some(true), - allowed_domains: Some(vec!["github.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([( + "github.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }), parent_config.permissions.sandbox_policy.get(), @@ -1027,7 +1034,9 @@ fn guardian_review_session_config_uses_live_network_proxy_state() { let mut parent_config = test_config(); let mut parent_network = NetworkProxyConfig::default(); parent_network.network.enabled = true; - parent_network.network.allowed_domains = vec!["parent.example".to_string()]; + parent_network + .network + .set_allowed_domains(vec!["parent.example".to_string()]); parent_config.permissions.network = Some( NetworkProxySpec::from_config_and_constraints( parent_network, @@ -1039,7 +1048,9 @@ fn guardian_review_session_config_uses_live_network_proxy_state() { let mut live_network = NetworkProxyConfig::default(); live_network.network.enabled = true; - live_network.network.allowed_domains = vec!["github.com".to_string()]; + live_network + .network + .set_allowed_domains(vec!["github.com".to_string()]); let guardian_config = build_guardian_review_session_config_for_test( &parent_config, diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 1db111ee63..a6740b05fb 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -1,6 +1,7 @@ use crate::config::NetworkToml; use crate::config::PermissionsToml; use crate::config::find_codex_home; +use crate::config::overlay_network_domain_permissions; use crate::config::resolve_permission_profile; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; @@ -150,13 +151,20 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets { constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); } - if let Some(allowed_domains) = network.allowed_domains { - constraints.allowed_domains = Some(allowed_domains); + if let Some(domains) = network.domains.as_ref() { + let mut config = NetworkProxyConfig::default(); + if let Some(allowed_domains) = constraints.allowed_domains.take() { + config.network.set_allowed_domains(allowed_domains); + } + if let Some(denied_domains) = constraints.denied_domains.take() { + config.network.set_denied_domains(denied_domains); + } + overlay_network_domain_permissions(&mut config, domains); + constraints.allowed_domains = config.network.allowed_domains(); + constraints.denied_domains = config.network.denied_domains(); } - if let Some(denied_domains) = network.denied_domains { - constraints.denied_domains = Some(denied_domains); - } - if let Some(allow_unix_sockets) = network.allow_unix_sockets { + if let Some(unix_sockets) = network.unix_sockets.as_ref() { + let allow_unix_sockets = unix_sockets.allow_unix_sockets(); constraints.allow_unix_sockets = Some(allow_unix_sockets); } if let Some(allow_local_binding) = network.allow_local_binding { @@ -220,24 +228,28 @@ fn apply_exec_policy_network_rules( let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains(); for host in allowed_domains { upsert_network_domain( - &mut config.network.allowed_domains, - &mut config.network.denied_domains, + config, host, + codex_network_proxy::NetworkDomainPermission::Allow, ); } for host in denied_domains { upsert_network_domain( - &mut config.network.denied_domains, - &mut config.network.allowed_domains, + config, host, + codex_network_proxy::NetworkDomainPermission::Deny, ); } } -fn upsert_network_domain(target: &mut Vec, opposite: &mut Vec, host: String) { - opposite.retain(|entry| normalize_host(entry) != host); - target.retain(|entry| normalize_host(entry) != host); - target.push(host); +fn upsert_network_domain( + config: &mut NetworkProxyConfig, + host: String, + permission: codex_network_proxy::NetworkDomainPermission, +) { + config + .network + .upsert_domain_permission(host, permission, normalize_host); } fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs index 018061463b..395b761dd5 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -6,13 +6,16 @@ use codex_execpolicy::Policy; use pretty_assertions::assert_eq; #[test] -fn higher_precedence_profile_network_beats_lower_profile_network() { +fn higher_precedence_profile_network_overlays_domain_entries() { let lower_network: toml::Value = toml::from_str( r#" default_permissions = "workspace" [permissions.workspace.network] -allowed_domains = ["lower.example.com"] + +[permissions.workspace.network.domains] +"lower.example.com" = "allow" +"blocked.example.com" = "deny" "#, ) .expect("lower layer should parse"); @@ -21,7 +24,9 @@ allowed_domains = ["lower.example.com"] default_permissions = "workspace" [permissions.workspace.network] -allowed_domains = ["higher.example.com"] + +[permissions.workspace.network.domains] +"higher.example.com" = "allow" "#, ) .expect("higher layer should parse"); @@ -38,14 +43,76 @@ allowed_domains = ["higher.example.com"] ) .expect("higher layer should apply"); - assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); + assert_eq!( + config.network.allowed_domains(), + Some(vec![ + "lower.example.com".to_string(), + "higher.example.com".to_string() + ]) + ); + assert_eq!( + config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); +} + +#[test] +fn higher_precedence_profile_network_overrides_matching_domain_entries() { + let lower_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"shared.example.com" = "deny" +"other.example.com" = "allow" +"#, + ) + .expect("lower layer should parse"); + let higher_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"shared.example.com" = "allow" +"#, + ) + .expect("higher layer should parse"); + + let mut config = NetworkProxyConfig::default(); + apply_network_tables( + &mut config, + network_tables_from_toml(&lower_network).expect("lower layer should deserialize"), + ) + .expect("lower layer should apply"); + apply_network_tables( + &mut config, + network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), + ) + .expect("higher layer should apply"); + + assert_eq!( + config.network.allowed_domains(), + Some(vec![ + "other.example.com".to_string(), + "shared.example.com".to_string() + ]) + ); + assert_eq!(config.network.denied_domains(), None); } #[test] fn execpolicy_network_rules_overlay_network_lists() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["config.example.com".to_string()]; - config.network.denied_domains = vec!["blocked.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["config.example.com".to_string()]); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); let mut exec_policy = Policy::empty(); exec_policy @@ -68,15 +135,15 @@ fn execpolicy_network_rules_overlay_network_lists() { apply_exec_policy_network_rules(&mut config, &exec_policy); assert_eq!( - config.network.allowed_domains, - vec![ + config.network.allowed_domains(), + Some(vec![ "config.example.com".to_string(), "blocked.example.com".to_string() - ] + ]) ); assert_eq!( - config.network.denied_domains, - vec!["api.example.com".to_string()] + config.network.denied_domains(), + Some(vec!["api.example.com".to_string()]) ); } @@ -102,3 +169,82 @@ dangerously_allow_all_unix_sockets = true assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); } + +#[test] +fn apply_network_constraints_skips_empty_domain_sides() { + let config: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"managed.example.com" = "allow" +"#, + ) + .expect("permissions profile should parse"); + let network = selected_network_from_tables( + network_tables_from_toml(&config).expect("permissions profile should deserialize"), + ) + .expect("permissions profile should select a network table") + .expect("network table should be present"); + + let mut constraints = NetworkProxyConstraints::default(); + apply_network_constraints(network, &mut constraints); + + assert_eq!( + constraints.allowed_domains, + Some(vec!["managed.example.com".to_string()]) + ); + assert_eq!(constraints.denied_domains, None); +} + +#[test] +fn apply_network_constraints_overlay_domain_entries() { + let lower_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"blocked.example.com" = "deny" +"#, + ) + .expect("lower layer should parse"); + let higher_network: toml::Value = toml::from_str( + r#" +default_permissions = "workspace" + +[permissions.workspace.network] + +[permissions.workspace.network.domains] +"api.example.com" = "allow" +"#, + ) + .expect("higher layer should parse"); + + let lower_network = selected_network_from_tables( + network_tables_from_toml(&lower_network).expect("lower layer should deserialize"), + ) + .expect("lower layer should select a network table") + .expect("lower network table should be present"); + let higher_network = selected_network_from_tables( + network_tables_from_toml(&higher_network).expect("higher layer should deserialize"), + ) + .expect("higher layer should select a network table") + .expect("higher network table should be present"); + + let mut constraints = NetworkProxyConstraints::default(); + apply_network_constraints(lower_network, &mut constraints); + apply_network_constraints(higher_network, &mut constraints); + + assert_eq!( + constraints.allowed_domains, + Some(vec!["api.example.com".to_string()]) + ); + assert_eq!( + constraints.denied_domains, + Some(vec!["blocked.example.com".to_string()]) + ); +} diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 0347e6ddf2..d9eecf4f6a 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -37,24 +37,29 @@ mode = "full" # default when unset; use "limited" for read-only mode mitm = false # CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). -# Hosts must match the allowlist (unless denied). -# Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. -# The global `*` wildcard is allowed in `allowed_domains` to delegate public-host filtering to -# `denied_domains`. -# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. -allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"] -denied_domains = ["evil.example"] - # If false, local/private networking is rejected. Explicit allowlisting of local IP literals # (or `localhost`) is required to permit them. # Hostnames that resolve to local/private IPs are still blocked even if allowlisted. allow_local_binding = false -# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. -allow_unix_sockets = ["/tmp/example.sock"] # DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any # absolute socket path from `x-unix-socket`. dangerously_allow_all_unix_sockets = false + +# Hosts must match the allowlist (unless denied). +# Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. +# The global `*` wildcard is rejected. +# If no domain entries are marked `allow`, the proxy blocks requests until an allowlist is configured. +[permissions.workspace.network.domains] +"*.openai.com" = "allow" +"localhost" = "allow" +"127.0.0.1" = "allow" +"::1" = "allow" +"evil.example" = "deny" + +# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. +[permissions.workspace.network.unix_sockets] +"/tmp/example.sock" = "allow" ``` ### 2) Run the proxy @@ -124,7 +129,7 @@ let handle = proxy.run().await?; handle.shutdown().await?; ``` -When unix socket proxying is enabled (`allow_unix_sockets` or +When unix socket proxying is enabled (`unix_sockets` or `dangerously_allow_all_unix_sockets`), proxy bind overrides are still clamped to loopback to avoid turning the proxy into a remote bridge to local daemons. @@ -189,9 +194,9 @@ Audit events intentionally avoid logging full URL/path/query data. This section documents the protections implemented by `codex-network-proxy`, and the boundaries of what it can reasonably guarantee. -- Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. -- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported. A global `*` wildcard is allowed in `allowed_domains` to permit all public hosts by default, while `denied_domains` remains field-specific and still rejects global `*`. -- Deny wins: entries in `denied_domains` always override the allowlist. +- Allowlist-first policy: if `domains` has no `allow` entries, requests are blocked until an allowlist is configured. +- Domain patterns: exact hosts are supported, `*.example.com` matches subdomains only, and `**.example.com` matches the apex plus subdomains; the global `*` wildcard is only accepted when explicitly enabled for allowlist compilation and is otherwise rejected. +- Deny wins: `domains` entries marked `deny` always override the allowlist. - Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) is required to permit them; hostnames that resolve to local/private IPs are still blocked even if diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index f3f0e06c34..b081abb15f 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -3,7 +3,10 @@ use anyhow::Result; use anyhow::bail; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::Serializer; +use std::collections::BTreeMap; use std::net::IpAddr; use std::net::SocketAddr; use std::path::Path; @@ -16,6 +19,101 @@ pub struct NetworkProxyConfig { pub network: NetworkProxySettings, } +/// Variant order encodes effective precedence for duplicate patterns: +/// `None < Allow < Deny`, so deny wins over allow when entries conflict. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermission { + None, + Allow, + Deny, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkDomainPermissionEntry { + pub pattern: String, + pub permission: NetworkDomainPermission, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkDomainPermissions { + pub entries: Vec, +} + +impl Serialize for NetworkDomainPermissions { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + self.effective_entries() + .into_iter() + .map(|entry| (entry.pattern, entry.permission)) + .collect::>() + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for NetworkDomainPermissions { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let entries = BTreeMap::::deserialize(deserializer)? + .into_iter() + .map(|(pattern, permission)| NetworkDomainPermissionEntry { + pattern, + permission, + }) + .collect(); + Ok(Self { entries }) + } +} + +impl NetworkDomainPermissions { + fn effective_entries(&self) -> Vec { + let mut order = Vec::new(); + let mut effective_permissions = BTreeMap::new(); + + for entry in &self.entries { + if !effective_permissions.contains_key(&entry.pattern) { + order.push(entry.pattern.clone()); + } + + let permission = effective_permissions + .entry(entry.pattern.clone()) + .or_insert(entry.permission); + if entry.permission > *permission { + *permission = entry.permission; + } + } + + order + .into_iter() + .filter_map(|pattern| { + effective_permissions.remove(&pattern).map(|permission| { + NetworkDomainPermissionEntry { + pattern, + permission, + } + }) + }) + .collect() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermission { + Allow, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct NetworkUnixSocketPermissions { + #[serde(flatten)] + pub entries: BTreeMap, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct NetworkProxySettings { @@ -35,11 +133,9 @@ pub struct NetworkProxySettings { #[serde(default)] pub mode: NetworkMode, #[serde(default)] - pub allowed_domains: Vec, + pub domains: Option, #[serde(default)] - pub denied_domains: Vec, - #[serde(default)] - pub allow_unix_sockets: Vec, + pub unix_sockets: Option, pub allow_local_binding: bool, #[serde(default)] pub mitm: bool, @@ -57,15 +153,119 @@ impl Default for NetworkProxySettings { dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::default(), - allowed_domains: Vec::new(), - denied_domains: Vec::new(), - allow_unix_sockets: Vec::new(), + domains: None, + unix_sockets: None, allow_local_binding: false, mitm: false, } } } +impl NetworkProxySettings { + pub fn allowed_domains(&self) -> Option> { + self.domain_entries(NetworkDomainPermission::Allow) + } + + pub fn denied_domains(&self) -> Option> { + self.domain_entries(NetworkDomainPermission::Deny) + } + + fn domain_entries(&self, permission: NetworkDomainPermission) -> Option> { + self.domains + .as_ref() + .map(|domains| { + domains + .effective_entries() + .iter() + .filter(|entry| entry.permission == permission) + .map(|entry| entry.pattern.clone()) + .collect() + }) + .filter(|entries: &Vec| !entries.is_empty()) + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.unix_sockets + .as_ref() + .map(|unix_sockets| { + unix_sockets + .entries + .iter() + .filter(|(_, permission)| { + matches!(permission, NetworkUnixSocketPermission::Allow) + }) + .map(|(path, _)| path.clone()) + .collect() + }) + .unwrap_or_default() + } + + pub fn set_allowed_domains(&mut self, allowed_domains: Vec) { + self.set_domain_entries(allowed_domains, NetworkDomainPermission::Allow); + } + + pub fn set_denied_domains(&mut self, denied_domains: Vec) { + self.set_domain_entries(denied_domains, NetworkDomainPermission::Deny); + } + + pub fn upsert_domain_permission( + &mut self, + host: String, + permission: NetworkDomainPermission, + normalize: impl Fn(&str) -> String, + ) { + let mut domains = self.domains.take().unwrap_or_default(); + let normalized_host = normalize(&host); + domains + .entries + .retain(|entry| normalize(&entry.pattern) != normalized_host); + domains.entries.push(NetworkDomainPermissionEntry { + pattern: host, + permission, + }); + self.domains = (!domains.entries.is_empty()).then_some(domains); + } + + pub fn set_allow_unix_sockets(&mut self, allow_unix_sockets: Vec) { + self.set_unix_socket_entries(allow_unix_sockets, NetworkUnixSocketPermission::Allow); + } + + fn set_domain_entries(&mut self, entries: Vec, permission: NetworkDomainPermission) { + let mut domains = self.domains.take().unwrap_or_default(); + domains + .entries + .retain(|entry| entry.permission != permission); + for entry in entries { + if !domains + .entries + .iter() + .any(|existing| existing.pattern == entry && existing.permission == permission) + { + domains.entries.push(NetworkDomainPermissionEntry { + pattern: entry, + permission, + }); + } + } + self.domains = (!domains.entries.is_empty()).then_some(domains); + } + + fn set_unix_socket_entries( + &mut self, + entries: Vec, + permission: NetworkUnixSocketPermission, + ) { + let mut unix_sockets = self.unix_sockets.take().unwrap_or_default(); + unix_sockets + .entries + .retain(|_, existing| *existing != permission); + for entry in entries { + unix_sockets.entries.insert(entry, permission); + } + self.unix_sockets = (!unix_sockets.entries.is_empty()).then_some(unix_sockets); + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum NetworkMode { @@ -136,7 +336,7 @@ pub(crate) fn clamp_bind_addrs( "SOCKS5 proxy", "dangerously_allow_non_loopback_proxy", ); - if cfg.allow_unix_sockets.is_empty() && !cfg.dangerously_allow_all_unix_sockets { + if cfg.allow_unix_sockets().is_empty() && !cfg.dangerously_allow_all_unix_sockets { return (http_addr, socks_addr); } @@ -198,7 +398,7 @@ impl ValidatedUnixSocketPath { } pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> { - for (index, socket_path) in cfg.network.allow_unix_sockets.iter().enumerate() { + for (index, socket_path) in cfg.network.allow_unix_sockets().iter().enumerate() { ValidatedUnixSocketPath::parse(socket_path) .with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?; } @@ -357,6 +557,19 @@ mod tests { use pretty_assertions::assert_eq; + fn settings_with_unix_sockets(unix_sockets: &[&str]) -> NetworkProxySettings { + let mut settings = NetworkProxySettings::default(); + if !unix_sockets.is_empty() { + settings.set_allow_unix_sockets( + unix_sockets + .iter() + .map(|path| (*path).to_string()) + .collect(), + ); + } + settings + } + #[test] fn network_proxy_settings_default_matches_local_use_baseline() { assert_eq!( @@ -371,9 +584,8 @@ mod tests { dangerously_allow_non_loopback_proxy: false, dangerously_allow_all_unix_sockets: false, mode: NetworkMode::Full, - allowed_domains: Vec::new(), - denied_domains: Vec::new(), - allow_unix_sockets: Vec::new(), + domains: None, + unix_sockets: None, allow_local_binding: false, mitm: false, } @@ -398,6 +610,53 @@ mod tests { assert_eq!(config.network, expected); } + #[test] + fn set_allowed_domains_preserves_existing_deny_for_same_pattern() { + let mut settings = NetworkProxySettings::default(); + settings.set_denied_domains(vec!["example.com".to_string()]); + + settings.set_allowed_domains(vec!["example.com".to_string()]); + + assert_eq!(settings.allowed_domains(), None); + assert_eq!( + settings.denied_domains(), + Some(vec!["example.com".to_string()]) + ); + } + + #[test] + fn network_domain_permissions_serialize_to_effective_map_shape() { + let mut settings = NetworkProxySettings::default(); + settings.set_denied_domains(vec!["example.com".to_string()]); + settings.set_allowed_domains(vec!["example.com".to_string()]); + let config = NetworkProxyConfig { network: settings }; + + let value = serde_json::to_value(&config).unwrap(); + + assert_eq!( + value, + serde_json::json!({ + "network": { + "enabled": false, + "proxy_url": "http://127.0.0.1:3128", + "enable_socks5": true, + "socks_url": "http://127.0.0.1:8081", + "enable_socks5_udp": true, + "allow_upstream_proxy": true, + "dangerously_allow_non_loopback_proxy": false, + "dangerously_allow_all_unix_sockets": false, + "mode": "full", + "domains": { + "example.com": "deny", + }, + "unix_sockets": null, + "allow_local_binding": false, + "mitm": false, + } + }) + ); + } + #[test] fn parse_host_port_defaults_for_empty_string() { assert!(parse_host_port("", 1234).is_err()); @@ -536,10 +795,10 @@ mod tests { #[test] fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() { - let cfg = NetworkProxySettings { - dangerously_allow_non_loopback_proxy: true, - allow_unix_sockets: vec!["/tmp/docker.sock".to_string()], - ..Default::default() + let cfg = { + let mut settings = settings_with_unix_sockets(&["/tmp/docker.sock"]); + settings.dangerously_allow_non_loopback_proxy = true; + settings }; let http_addr = "0.0.0.0:3128".parse::().unwrap(); let socks_addr = "0.0.0.0:8081".parse::().unwrap(); @@ -569,10 +828,7 @@ mod tests { #[test] fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() { let cfg = NetworkProxyConfig { - network: NetworkProxySettings { - allow_unix_sockets: vec!["relative.sock".to_string()], - ..NetworkProxySettings::default() - }, + network: settings_with_unix_sockets(&["relative.sock"]), }; let err = match resolve_runtime(&cfg) { @@ -591,10 +847,7 @@ mod tests { #[test] fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() { let cfg = NetworkProxyConfig { - network: NetworkProxySettings { - allow_unix_sockets: vec!["/private/tmp/example.sock".to_string()], - ..NetworkProxySettings::default() - }, + network: settings_with_unix_sockets(&["/private/tmp/example.sock"]), }; assert!( diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index a3da7e2ddd..1be610feba 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -1002,9 +1002,10 @@ mod tests { #[tokio::test] async fn http_connect_accept_blocks_in_limited_mode() { - let policy = NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..Default::default() + let policy = { + let mut policy = NetworkProxySettings::default(); + policy.set_allowed_domains(vec!["example.com".to_string()]); + policy }; let state = Arc::new(network_proxy_state_for_policy(policy)); state.set_network_mode(NetworkMode::Limited).await.unwrap(); @@ -1027,9 +1028,10 @@ mod tests { #[tokio::test] async fn http_connect_accept_allows_allowlisted_host_in_full_mode() { - let policy = NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..Default::default() + let policy = { + let mut policy = NetworkProxySettings::default(); + policy.set_allowed_domains(vec!["example.com".to_string()]); + policy }; let state = Arc::new(network_proxy_state_for_policy(policy)); @@ -1062,10 +1064,11 @@ mod tests { let _ = timeout(Duration::from_secs(1), stream.read(&mut buf)).await; }); - let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["127.0.0.1".to_string()], - allow_local_binding: true, - ..NetworkProxySettings::default() + let state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["127.0.0.1".to_string()]); + network.allow_local_binding = true; + network })); let listener = StdTcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("proxy listener should bind"); @@ -1161,9 +1164,10 @@ mod tests { #[cfg(target_os = "macos")] #[tokio::test(flavor = "current_thread")] async fn http_plain_proxy_attempts_allowed_unix_socket_proxy() { - let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allow_unix_sockets: vec!["/tmp/test.sock".to_string()], - ..NetworkProxySettings::default() + let state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allow_unix_sockets(vec!["/tmp/test.sock".to_string()]); + network })); let mut req = Request::builder() @@ -1180,10 +1184,11 @@ mod tests { #[tokio::test] async fn http_connect_accept_denies_denylisted_host() { - let policy = NetworkProxySettings { - allowed_domains: vec!["**.openai.com".to_string()], - denied_domains: vec!["api.openai.com".to_string()], - ..Default::default() + let policy = { + let mut policy = NetworkProxySettings::default(); + policy.set_allowed_domains(vec!["**.openai.com".to_string()]); + policy.set_denied_domains(vec!["api.openai.com".to_string()]); + policy }; let state = Arc::new(network_proxy_state_for_policy(policy)); diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 1093a14aa4..4fb313805c 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -14,8 +14,13 @@ mod socks5; mod state; mod upstream; +pub use config::NetworkDomainPermission; +pub use config::NetworkDomainPermissionEntry; +pub use config::NetworkDomainPermissions; pub use config::NetworkMode; pub use config::NetworkProxyConfig; +pub use config::NetworkUnixSocketPermission; +pub use config::NetworkUnixSocketPermissions; pub use config::host_and_port_from_network_addr; pub use network_policy::NetworkDecision; pub use network_policy::NetworkDecisionSource; diff --git a/codex-rs/network-proxy/src/mitm_tests.rs b/codex-rs/network-proxy/src/mitm_tests.rs index d2f7984a70..28eb5c5f79 100644 --- a/codex-rs/network-proxy/src/mitm_tests.rs +++ b/codex-rs/network-proxy/src/mitm_tests.rs @@ -26,9 +26,10 @@ fn policy_ctx( #[tokio::test] async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() { - let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() + let app_state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network })); let ctx = policy_ctx(app_state.clone(), NetworkMode::Limited, "example.com", 443); let req = Request::builder() @@ -59,9 +60,10 @@ async fn mitm_policy_blocks_disallowed_method_and_records_telemetry() { #[tokio::test] async fn mitm_policy_rejects_host_mismatch() { - let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() + let app_state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network })); let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "example.com", 443); let req = Request::builder() @@ -82,10 +84,11 @@ async fn mitm_policy_rejects_host_mismatch() { #[tokio::test] async fn mitm_policy_rechecks_local_private_target_after_connect() { - let app_state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() + let app_state = Arc::new(network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network.allow_local_binding = false; + network })); let ctx = policy_ctx(app_state.clone(), NetworkMode::Full, "10.0.0.1", 443); let req = Request::builder() diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index e759e75c0d..f9670c0550 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -676,10 +676,11 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - denied_domains: vec!["blocked.com".to_string()], - ..NetworkProxySettings::default() + let state = network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network.set_denied_domains(vec!["blocked.com".to_string()]); + network }); let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Http, @@ -850,10 +851,11 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn evaluate_host_policy_still_denies_not_allowed_local_without_decider_override() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() + let state = network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network.allow_local_binding = false; + network }); let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Http, diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 3eecef3ee7..3dc6f6bad8 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -220,7 +220,7 @@ impl NetworkProxyBuilder { socks_addr, socks_enabled: current_cfg.network.enable_socks5, allow_local_binding: current_cfg.network.allow_local_binding, - allow_unix_sockets: current_cfg.network.allow_unix_sockets.clone(), + allow_unix_sockets: current_cfg.network.allow_unix_sockets(), dangerously_allow_all_unix_sockets: current_cfg .network .dangerously_allow_all_unix_sockets, diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index ec43a54792..c03c3da4a5 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -1,3 +1,4 @@ +use crate::config::NetworkDomainPermission; use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::config::ValidatedUnixSocketPath; @@ -295,8 +296,8 @@ impl NetworkProxyState { self.reload_if_needed().await?; let guard = self.state.read().await; Ok(( - guard.config.network.allowed_domains.clone(), - guard.config.network.denied_domains.clone(), + guard.config.network.allowed_domains().unwrap_or_default(), + guard.config.network.denied_domains().unwrap_or_default(), )) } @@ -340,16 +341,18 @@ impl NetworkProxyState { Ok(host) => host, Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)), }; - let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = { + let (deny_set, allow_set, allow_local_binding, allowed_domains) = { let guard = self.state.read().await; + let allowed_domains = guard.config.network.allowed_domains(); ( guard.deny_set.clone(), guard.allow_set.clone(), guard.config.network.allow_local_binding, - guard.config.network.allowed_domains.is_empty(), - guard.config.network.allowed_domains.clone(), + allowed_domains, ) }; + let allowed_domains_empty = allowed_domains.is_none(); + let allowed_domains = allowed_domains.unwrap_or_default(); let host_str = host.as_str(); @@ -481,7 +484,7 @@ impl NetworkProxyState { Err(_) => return Ok(false), }; let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); - for allowed in &guard.config.network.allow_unix_sockets { + for allowed in &guard.config.network.allow_unix_sockets() { let allowed_path = match ValidatedUnixSocketPath::parse(allowed) { Ok(ValidatedUnixSocketPath::Native(path)) => path, Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue, @@ -585,7 +588,8 @@ impl NetworkProxyState { }; let mut candidate = previous_cfg.clone(); - let (target_entries, opposite_entries) = candidate.split_domain_lists_mut(target); + let target_entries = target.entries(&candidate.network); + let opposite_entries = target.opposite_entries(&candidate.network); let target_contains = target_entries .iter() .any(|entry| normalize_host(entry) == normalized_host); @@ -596,9 +600,11 @@ impl NetworkProxyState { return Ok(()); } - target_entries.retain(|entry| normalize_host(entry) != normalized_host); - target_entries.push(normalized_host.clone()); - opposite_entries.retain(|entry| normalize_host(entry) != normalized_host); + candidate.network.upsert_domain_permission( + normalized_host.clone(), + target.permission(), + normalize_host, + ); validate_policy_against_constraints(&candidate, &constraints) .map_err(NetworkProxyConstraintError::into_anyhow) @@ -669,22 +675,25 @@ impl DomainListKind { Self::Deny => "network.denied_domains", } } -} -impl NetworkProxyConfig { - fn split_domain_lists_mut( - &mut self, - target: DomainListKind, - ) -> (&mut Vec, &mut Vec) { - match target { - DomainListKind::Allow => ( - &mut self.network.allowed_domains, - &mut self.network.denied_domains, - ), - DomainListKind::Deny => ( - &mut self.network.denied_domains, - &mut self.network.allowed_domains, - ), + fn permission(self) -> NetworkDomainPermission { + match self { + Self::Allow => NetworkDomainPermission::Allow, + Self::Deny => NetworkDomainPermission::Deny, + } + } + + fn entries(self, network: &crate::config::NetworkProxySettings) -> Vec { + match self { + Self::Allow => network.allowed_domains().unwrap_or_default(), + Self::Deny => network.denied_domains().unwrap_or_default(), + } + } + + fn opposite_entries(self, network: &crate::config::NetworkProxySettings) -> Vec { + match self { + Self::Allow => network.denied_domains().unwrap_or_default(), + Self::Deny => network.allowed_domains().unwrap_or_default(), } } } @@ -726,16 +735,16 @@ async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool { } fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) { + let previous_allowed_domains = previous.network.allowed_domains().unwrap_or_default(); + let next_allowed_domains = next.network.allowed_domains().unwrap_or_default(); log_domain_list_changes( "allowlist", - &previous.network.allowed_domains, - &next.network.allowed_domains, - ); - log_domain_list_changes( - "denylist", - &previous.network.denied_domains, - &next.network.denied_domains, + &previous_allowed_domains, + &next_allowed_domains, ); + let previous_denied_domains = previous.network.denied_domains().unwrap_or_default(); + let next_denied_domains = next.network.denied_domains().unwrap_or_default(); + log_domain_list_changes("denylist", &previous_denied_domains, &next_denied_domains); } fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) { @@ -836,13 +845,37 @@ mod tests { use crate::state::validate_policy_against_constraints; use pretty_assertions::assert_eq; + fn strings(entries: &[&str]) -> Vec { + entries.iter().map(|entry| (*entry).to_string()).collect() + } + + fn network_settings(allowed_domains: &[&str], denied_domains: &[&str]) -> NetworkProxySettings { + let mut network = NetworkProxySettings::default(); + if !allowed_domains.is_empty() { + network.set_allowed_domains(strings(allowed_domains)); + } + if !denied_domains.is_empty() { + network.set_denied_domains(strings(denied_domains)); + } + network + } + + fn network_settings_with_unix_sockets( + allowed_domains: &[&str], + denied_domains: &[&str], + unix_sockets: &[String], + ) -> NetworkProxySettings { + let mut network = network_settings(allowed_domains, denied_domains); + if !unix_sockets.is_empty() { + network.set_allow_unix_sockets(unix_sockets.to_vec()); + } + network + } + #[tokio::test] async fn host_blocked_denied_wins_over_allowed() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - denied_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = + network_proxy_state_for_policy(network_settings(&["example.com"], &["example.com"])); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), @@ -852,10 +885,7 @@ mod tests { #[tokio::test] async fn host_blocked_requires_allowlist_match() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), @@ -871,10 +901,7 @@ mod tests { #[tokio::test] async fn add_allowed_domain_removes_matching_deny_entry() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - denied_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&[], &["example.com"])); state.add_allowed_domain("ExAmPlE.CoM").await.unwrap(); @@ -889,10 +916,7 @@ mod tests { #[tokio::test] async fn add_denied_domain_removes_matching_allow_entry() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); state.add_denied_domain("EXAMPLE.COM").await.unwrap(); @@ -907,10 +931,7 @@ mod tests { #[tokio::test] async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["*"], &[])); assert_eq!( // Use a public IP literal to avoid relying on ambient DNS behavior. @@ -932,10 +953,10 @@ mod tests { #[tokio::test] async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["managed.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["managed.example.com"], &[]); + network.enabled = true; + network }, }; let constraints = NetworkProxyConstraints { @@ -964,10 +985,10 @@ mod tests { #[tokio::test] async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["managed.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["managed.example.com"], &[]); + network.enabled = true; + network }, }; let constraints = NetworkProxyConstraints { @@ -994,10 +1015,10 @@ mod tests { #[tokio::test] async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - denied_domains: vec!["managed.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&[], &["managed.example.com"]); + network.enabled = true; + network }, }; let constraints = NetworkProxyConstraints { @@ -1109,10 +1130,7 @@ mod tests { #[tokio::test] async fn host_blocked_subdomain_wildcards_exclude_apex() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*.openai.com".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["*.openai.com"], &[])); assert_eq!( state.host_blocked("api.openai.com", 80).await.unwrap(), @@ -1126,11 +1144,7 @@ mod tests { #[tokio::test] async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["*".to_string()], - denied_domains: vec!["evil.example".to_string()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["*"], &["evil.example"])); assert_eq!( state.host_blocked("example.com", 80).await.unwrap(), @@ -1148,11 +1162,7 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_loopback_when_local_binding_disabled() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); assert_eq!( state.host_blocked("127.0.0.1", 80).await.unwrap(), @@ -1166,11 +1176,7 @@ mod tests { #[tokio::test] async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["localhost".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["localhost"], &[])); assert_eq!( state.host_blocked("localhost", 80).await.unwrap(), @@ -1180,11 +1186,7 @@ mod tests { #[tokio::test] async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["10.0.0.1".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["10.0.0.1"], &[])); assert_eq!( state.host_blocked("10.0.0.1", 80).await.unwrap(), @@ -1194,11 +1196,7 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); assert_eq!( state.host_blocked("fe80::1%lo0", 80).await.unwrap(), @@ -1208,11 +1206,7 @@ mod tests { #[tokio::test] async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["fe80::1%lo0".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["fe80::1%lo0"], &[])); assert_eq!( state.host_blocked("fe80::1%lo0", 80).await.unwrap(), @@ -1222,11 +1216,7 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[])); assert_eq!( state.host_blocked("10.0.0.1", 80).await.unwrap(), @@ -1236,11 +1226,7 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_loopback_when_allowlist_empty() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec![], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(NetworkProxySettings::default()); assert_eq!( state.host_blocked("127.0.0.1", 80).await.unwrap(), @@ -1250,11 +1236,9 @@ mod tests { #[tokio::test] async fn host_blocked_rejects_allowlisted_hostname_when_dns_lookup_fails() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["does-not-resolve.invalid".to_string()], - allow_local_binding: false, - ..NetworkProxySettings::default() - }); + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["does-not-resolve.invalid".to_string()]); + let state = network_proxy_state_for_policy(network); assert_eq!( state @@ -1273,10 +1257,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["example.com".to_string(), "evil.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["example.com", "evil.com"], &[]); + network.enabled = true; + network }, }; @@ -1292,10 +1276,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["example.com".to_string(), "api.openai.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["example.com", "api.openai.com"], &[]); + network.enabled = true; + network }, }; @@ -1328,10 +1312,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["api.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["api.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1346,10 +1330,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["**.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["**.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1364,10 +1348,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["api.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["api.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1383,10 +1367,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["api.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["api.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1402,10 +1386,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["api.example.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["api.example.com"], &[]); + network.enabled = true; + network }, }; @@ -1422,7 +1406,6 @@ mod tests { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, - denied_domains: vec![], ..NetworkProxySettings::default() }, }; @@ -1439,10 +1422,10 @@ mod tests { }; let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - denied_domains: vec!["evil.com".to_string(), "more-evil.com".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&[], &["evil.com", "more-evil.com"]); + network.enabled = true; + network }, }; @@ -1626,10 +1609,10 @@ mod tests { #[test] fn build_config_state_allows_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["*".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["*"], &[]); + network.enabled = true; + network }, }; @@ -1639,10 +1622,10 @@ mod tests { #[test] fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["[*]".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["[*]"], &[]); + network.enabled = true; + network }, }; @@ -1652,11 +1635,10 @@ mod tests { #[test] fn build_config_state_rejects_global_wildcard_denied_domains() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["example.com".to_string()], - denied_domains: vec!["*".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["example.com"], &["*"]); + network.enabled = true; + network }, }; @@ -1666,11 +1648,10 @@ mod tests { #[test] fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() { let config = NetworkProxyConfig { - network: NetworkProxySettings { - enabled: true, - allowed_domains: vec!["example.com".to_string()], - denied_domains: vec!["[*]".to_string()], - ..NetworkProxySettings::default() + network: { + let mut network = network_settings(&["example.com"], &["[*]"]); + network.enabled = true; + network }, }; @@ -1681,11 +1662,11 @@ mod tests { #[tokio::test] async fn unix_socket_allowlist_is_respected_on_macos() { let socket_path = "/tmp/example.sock".to_string(); - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_unix_sockets: vec![socket_path.clone()], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings_with_unix_sockets( + &["example.com"], + &[], + std::slice::from_ref(&socket_path), + )); assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap()); assert!( @@ -1716,11 +1697,11 @@ mod tests { let real_s = real.to_str().unwrap().to_string(); let link_s = link.to_str().unwrap().to_string(); - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_unix_sockets: vec![real_s], - ..NetworkProxySettings::default() - }); + let state = network_proxy_state_for_policy(network_settings_with_unix_sockets( + &["example.com"], + &[], + std::slice::from_ref(&real_s), + )); assert!(state.is_unix_socket_allowed(&link_s).await.unwrap()); } @@ -1728,10 +1709,10 @@ mod tests { #[cfg(target_os = "macos")] #[tokio::test] async fn unix_socket_allow_all_flag_bypasses_allowlist() { - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - dangerously_allow_all_unix_sockets: true, - ..NetworkProxySettings::default() + let state = network_proxy_state_for_policy({ + let mut network = network_settings(&["example.com"], &[]); + network.dangerously_allow_all_unix_sockets = true; + network }); assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap()); @@ -1742,11 +1723,14 @@ mod tests { #[tokio::test] async fn unix_socket_allowlist_is_rejected_on_non_macos() { let socket_path = "/tmp/example.sock".to_string(); - let state = network_proxy_state_for_policy(NetworkProxySettings { - allowed_domains: vec!["example.com".to_string()], - allow_unix_sockets: vec![socket_path.clone()], - dangerously_allow_all_unix_sockets: true, - ..NetworkProxySettings::default() + let state = network_proxy_state_for_policy({ + let mut network = network_settings_with_unix_sockets( + &["example.com"], + &[], + std::slice::from_ref(&socket_path), + ); + network.dangerously_allow_all_unix_sockets = true; + network }); assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap()); diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 435e5beab4..01e4966b5a 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -1,5 +1,7 @@ +use crate::config::NetworkDomainPermissions; use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; +use crate::config::NetworkUnixSocketPermissions; use crate::mitm::MitmState; use crate::policy::DomainPattern; use crate::policy::compile_allowlist_globset; @@ -46,12 +48,9 @@ pub struct PartialNetworkConfig { pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, #[serde(default)] - pub allowed_domains: Option>, - #[serde(default)] - pub denied_domains: Option>, - #[serde(default)] - pub allow_unix_sockets: Option>, + pub domains: Option, #[serde(default)] + pub unix_sockets: Option, pub allow_local_binding: Option, } @@ -60,10 +59,12 @@ pub fn build_config_state( constraints: NetworkProxyConstraints, ) -> anyhow::Result { crate::config::validate_unix_socket_allowlist_paths(&config)?; - validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains) + let allowed_domains = config.network.allowed_domains().unwrap_or_default(); + let denied_domains = config.network.denied_domains().unwrap_or_default(); + validate_non_global_wildcard_domain_patterns("network.denied_domains", &denied_domains) .map_err(NetworkProxyConstraintError::into_anyhow)?; - let deny_set = compile_denylist_globset(&config.network.denied_domains)?; - let allow_set = compile_allowlist_globset(&config.network.allowed_domains)?; + let deny_set = compile_denylist_globset(&denied_domains)?; + let allow_set = compile_allowlist_globset(&allowed_domains)?; let mitm = if config.network.mitm { Some(Arc::new(MitmState::new( config.network.allow_upstream_proxy, @@ -106,7 +107,14 @@ pub fn validate_policy_against_constraints( } let enabled = config.network.enabled; - validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains)?; + let config_allowed_domains = config.network.allowed_domains().unwrap_or_default(); + let config_denied_domains = config.network.denied_domains().unwrap_or_default(); + let denied_domain_overrides: HashSet = config_denied_domains + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + let config_allow_unix_sockets = config.network.allow_unix_sockets(); + validate_non_global_wildcard_domain_patterns("network.denied_domains", &config_denied_domains)?; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { if *candidate && !max_enabled { @@ -206,20 +214,24 @@ pub fn validate_policy_against_constraints( } if let Some(allowed_domains) = &constraints.allowed_domains { + validate_non_global_wildcard_domain_patterns("network.allowed_domains", allowed_domains)?; match constraints.allowlist_expansion_enabled { Some(true) => { let required_set: HashSet = allowed_domains .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); - validate(config.network.allowed_domains.clone(), move |candidate| { + validate(config_allowed_domains, |candidate| { let candidate_set: HashSet = candidate .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); let missing: Vec = required_set .iter() - .filter(|entry| !candidate_set.contains(*entry)) + .filter(|entry| { + !candidate_set.contains(*entry) + && !denied_domain_overrides.contains(*entry) + }) .cloned() .collect(); if missing.is_empty() { @@ -238,12 +250,16 @@ pub fn validate_policy_against_constraints( .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); - validate(config.network.allowed_domains.clone(), move |candidate| { + validate(config_allowed_domains, |candidate| { let candidate_set: HashSet = candidate .iter() .map(|entry| entry.to_ascii_lowercase()) .collect(); - if candidate_set == required_set { + let expected_set: HashSet = required_set + .difference(&denied_domain_overrides) + .cloned() + .collect(); + if candidate_set == expected_set { Ok(()) } else { Err(invalid_value( @@ -259,7 +275,7 @@ pub fn validate_policy_against_constraints( .iter() .map(|entry| DomainPattern::parse_for_constraints(entry)) .collect(); - validate(config.network.allowed_domains.clone(), move |candidate| { + validate(config_allowed_domains, move |candidate| { let mut invalid = Vec::new(); for entry in candidate { let candidate_pattern = DomainPattern::parse_for_constraints(entry); @@ -285,14 +301,14 @@ pub fn validate_policy_against_constraints( } if let Some(denied_domains) = &constraints.denied_domains { - validate_denylist_domain_patterns("network.denied_domains", denied_domains)?; + validate_non_global_wildcard_domain_patterns("network.denied_domains", denied_domains)?; let required_set: HashSet = denied_domains .iter() .map(|s| s.to_ascii_lowercase()) .collect(); match constraints.denylist_expansion_enabled { Some(false) => { - validate(config.network.denied_domains.clone(), move |candidate| { + validate(config_denied_domains, move |candidate| { let candidate_set: HashSet = candidate .iter() .map(|entry| entry.to_ascii_lowercase()) @@ -309,7 +325,7 @@ pub fn validate_policy_against_constraints( })?; } Some(true) | None => { - validate(config.network.denied_domains.clone(), move |candidate| { + validate(config_denied_domains, move |candidate| { let candidate_set: HashSet = candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); let missing: Vec = required_set @@ -336,32 +352,29 @@ pub fn validate_policy_against_constraints( .iter() .map(|s| s.to_ascii_lowercase()) .collect(); - validate( - config.network.allow_unix_sockets.clone(), - move |candidate| { - let mut invalid = Vec::new(); - for entry in candidate { - if !allowed_set.contains(&entry.to_ascii_lowercase()) { - invalid.push(entry.clone()); - } + validate(config_allow_unix_sockets, move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + if !allowed_set.contains(&entry.to_ascii_lowercase()) { + invalid.push(entry.clone()); } - if invalid.is_empty() { - Ok(()) - } else { - Err(invalid_value( - "network.allow_unix_sockets", - format!("{invalid:?}"), - "subset of managed allow_unix_sockets", - )) - } - }, - )?; + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network.allow_unix_sockets", + format!("{invalid:?}"), + "subset of managed allow_unix_sockets", + )) + } + })?; } Ok(()) } -fn validate_denylist_domain_patterns( +fn validate_non_global_wildcard_domain_patterns( field_name: &'static str, patterns: &[String], ) -> Result<(), NetworkProxyConstraintError> { @@ -401,3 +414,6 @@ fn network_mode_rank(mode: NetworkMode) -> u8 { NetworkMode::Full => 1, } } + +#[cfg(test)] +mod tests {} diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 29a5cb7cdf..42cfa19586 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -5,6 +5,8 @@ use codex_core::config_loader::ConfigLayerEntry; use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::NetworkConstraints; +use codex_core::config_loader::NetworkDomainPermissionToml; +use codex_core::config_loader::NetworkUnixSocketPermissionToml; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; @@ -333,10 +335,9 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + unix_sockets, allow_local_binding, } = network; @@ -362,21 +363,24 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" )); } - if let Some(allowed_domains) = allowed_domains { - parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); + if let Some(domains) = domains { + parts.push(format!( + "domains={}", + format_network_permission_entries(&domains.entries, format_network_domain_permission) + )); } if let Some(managed_allowed_domains_only) = managed_allowed_domains_only { parts.push(format!( "managed_allowed_domains_only={managed_allowed_domains_only}" )); } - if let Some(denied_domains) = denied_domains { - parts.push(format!("denied_domains=[{}]", denied_domains.join(", "))); - } - if let Some(allow_unix_sockets) = allow_unix_sockets { + if let Some(unix_sockets) = unix_sockets { parts.push(format!( - "allow_unix_sockets=[{}]", - allow_unix_sockets.join(", ") + "unix_sockets={}", + format_network_permission_entries( + &unix_sockets.entries, + format_network_unix_socket_permission, + ) )); } if let Some(allow_local_binding) = allow_local_binding { @@ -386,6 +390,33 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { join_or_empty(parts) } +fn format_network_permission_entries( + entries: &std::collections::BTreeMap, + format_value: impl Fn(T) -> &'static str, +) -> String { + let parts = entries + .iter() + .map(|(key, value)| format!("{key}={}", format_value(*value))) + .collect::>(); + format!("{{{}}}", parts.join(", ")) +} + +fn format_network_domain_permission(permission: NetworkDomainPermissionToml) -> &'static str { + match permission { + NetworkDomainPermissionToml::Allow => "allow", + NetworkDomainPermissionToml::Deny => "deny", + } +} + +fn format_network_unix_socket_permission( + permission: NetworkUnixSocketPermissionToml, +) -> &'static str { + match permission { + NetworkUnixSocketPermissionToml::Allow => "allow", + NetworkUnixSocketPermissionToml::None => "none", + } +} + #[cfg(test)] mod tests { use super::render_debug_config_lines; @@ -400,6 +431,8 @@ mod tests { use codex_core::config_loader::McpServerIdentity; use codex_core::config_loader::McpServerRequirement; use codex_core::config_loader::NetworkConstraints; + use codex_core::config_loader::NetworkDomainPermissionToml; + use codex_core::config_loader::NetworkDomainPermissionsToml; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; @@ -516,7 +549,12 @@ mod tests { network: Some(Sourced::new( NetworkConstraints { enabled: Some(true), - allowed_domains: Some(vec!["example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }, RequirementSource::CloudRequirements, @@ -580,7 +618,7 @@ mod tests { assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); assert!(rendered.contains( - "experimental_network: enabled=true, allowed_domains=[example.com] (source: cloud requirements)" + "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" )); assert!(!rendered.contains(" - rules:")); } diff --git a/codex-rs/tui_app_server/src/debug_config.rs b/codex-rs/tui_app_server/src/debug_config.rs index 29a5cb7cdf..76f4a91a52 100644 --- a/codex-rs/tui_app_server/src/debug_config.rs +++ b/codex-rs/tui_app_server/src/debug_config.rs @@ -5,6 +5,8 @@ use codex_core::config_loader::ConfigLayerEntry; use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::NetworkConstraints; +use codex_core::config_loader::NetworkDomainPermissionToml; +use codex_core::config_loader::NetworkUnixSocketPermissionToml; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; @@ -333,10 +335,9 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + unix_sockets, allow_local_binding, } = network; @@ -362,21 +363,24 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" )); } - if let Some(allowed_domains) = allowed_domains { - parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); + if let Some(domains) = domains { + parts.push(format!( + "domains={}", + format_network_permission_entries(&domains.entries, format_network_domain_permission) + )); } if let Some(managed_allowed_domains_only) = managed_allowed_domains_only { parts.push(format!( "managed_allowed_domains_only={managed_allowed_domains_only}" )); } - if let Some(denied_domains) = denied_domains { - parts.push(format!("denied_domains=[{}]", denied_domains.join(", "))); - } - if let Some(allow_unix_sockets) = allow_unix_sockets { + if let Some(unix_sockets) = unix_sockets { parts.push(format!( - "allow_unix_sockets=[{}]", - allow_unix_sockets.join(", ") + "unix_sockets={}", + format_network_permission_entries( + &unix_sockets.entries, + format_network_unix_socket_permission, + ) )); } if let Some(allow_local_binding) = allow_local_binding { @@ -386,6 +390,33 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { join_or_empty(parts) } +fn format_network_permission_entries( + entries: &std::collections::BTreeMap, + format_value: impl Fn(T) -> &'static str, +) -> String { + let parts = entries + .iter() + .map(|(key, value)| format!("{key}={}", format_value(*value))) + .collect::>(); + format!("{{{}}}", parts.join(", ")) +} + +fn format_network_domain_permission(permission: NetworkDomainPermissionToml) -> &'static str { + match permission { + NetworkDomainPermissionToml::Allow => "allow", + NetworkDomainPermissionToml::Deny => "deny", + } +} + +fn format_network_unix_socket_permission( + permission: NetworkUnixSocketPermissionToml, +) -> &'static str { + match permission { + NetworkUnixSocketPermissionToml::Allow => "allow", + NetworkUnixSocketPermissionToml::None => "none", + } +} + #[cfg(test)] mod tests { use super::render_debug_config_lines; @@ -400,6 +431,10 @@ mod tests { use codex_core::config_loader::McpServerIdentity; use codex_core::config_loader::McpServerRequirement; use codex_core::config_loader::NetworkConstraints; + use codex_core::config_loader::NetworkDomainPermissionToml; + use codex_core::config_loader::NetworkDomainPermissionsToml; + use codex_core::config_loader::NetworkUnixSocketPermissionToml; + use codex_core::config_loader::NetworkUnixSocketPermissionsToml; use codex_core::config_loader::RequirementSource; use codex_core::config_loader::ResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement; @@ -516,7 +551,12 @@ mod tests { network: Some(Sourced::new( NetworkConstraints { enabled: Some(true), - allowed_domains: Some(vec!["example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }, RequirementSource::CloudRequirements, @@ -580,10 +620,45 @@ mod tests { assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); assert!(rendered.contains( - "experimental_network: enabled=true, allowed_domains=[example.com] (source: cloud requirements)" + "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" )); assert!(!rendered.contains(" - rules:")); } + + #[test] + fn debug_config_output_formats_unix_socket_permissions() { + let requirements = ConfigRequirements { + network: Some(Sourced::new( + NetworkConstraints { + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/codex.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/blocked.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }), + ..Default::default() + }, + RequirementSource::CloudRequirements, + )), + ..ConfigRequirements::default() + }; + + let stack = + ConfigLayerStack::new(Vec::new(), requirements, ConfigRequirementsToml::default()) + .expect("config layer stack"); + + let rendered = render_to_text(&render_debug_config_lines(&stack)); + assert!(rendered.contains( + "experimental_network: unix_sockets={/tmp/blocked.sock=none, /tmp/codex.sock=allow} (source: cloud requirements)" + )); + } + #[test] fn debug_config_output_lists_session_flag_key_value_pairs() { let session_flags = toml::from_str::( From 47a9e2e084e21542821ab65aae91f2bd6bf17c07 Mon Sep 17 00:00:00 2001 From: daniel-oai Date: Fri, 27 Mar 2026 00:27:15 -0700 Subject: [PATCH 010/185] Add ChatGPT device-code login to app server (#15525) ## Problem App-server clients could only initiate ChatGPT login through the browser callback flow, even though the shared login crate already supports device-code auth. That left VS Code, Codex App, and other app-server clients without a first-class way to use the existing device-code backend when browser redirects are brittle or when the client UX wants to own the login ceremony. ## Mental model This change adds a second ChatGPT login start path to app-server: clients can now call `account/login/start` with `type: "chatgptDeviceCode"`. App-server immediately returns a `loginId` plus the device-code UX payload (`verificationUrl` and `userCode`), then completes the login asynchronously in the background using the existing `codex_login` polling flow. Successful device-code login still resolves to ordinary `chatgpt` auth, and completion continues to flow through the existing `account/login/completed` and `account/updated` notifications. ## Non-goals This does not introduce a new auth mode, a new account shape, or a device-code eligibility discovery API. It also does not add automatic fallback to browser login in core; clients remain responsible for choosing when to request device code and whether to retry with a different UX if the backend/admin policy rejects it. ## Tradeoffs We intentionally keep `login_chatgpt_common` as a local validation helper instead of turning it into a capability probe. Device-code eligibility is checked by actually calling `request_device_code`, which means policy-disabled cases surface as an immediate request error rather than an async completion event. We also keep the active-login state machine minimal: browser and device-code logins share the same public cancel contract, but device-code cancellation is implemented with a local cancel token rather than a larger cross-crate refactor. ## Architecture The protocol grows a new `chatgptDeviceCode` request/response variant in app-server v2. On the server side, the new handler reuses the existing ChatGPT login precondition checks, calls `request_device_code`, returns the device-code payload, and then spawns a background task that waits on either cancellation or `complete_device_code_login`. On success, it reuses the existing auth reload and cloud-requirements refresh path before emitting `account/login/completed` success and `account/updated`. On failure or cancellation, it emits only `account/login/completed` failure. The existing `account/login/cancel { loginId }` contract remains unchanged and now works for both browser and device-code attempts. ## Tests Added protocol serialization coverage for the new request/response variant, plus app-server tests for device-code success, failure, cancel, and start-time rejection behavior. Existing browser ChatGPT login coverage remains in place to show that the callback-based flow is unchanged. --- .../schema/json/ClientRequest.json | 16 + .../codex_app_server_protocol.schemas.json | 46 +++ .../codex_app_server_protocol.v2.schemas.json | 46 +++ .../schema/json/v2/LoginAccountParams.json | 16 + .../schema/json/v2/LoginAccountResponse.json | 30 ++ .../typescript/v2/LoginAccountParams.ts | 2 +- .../typescript/v2/LoginAccountResponse.ts | 10 +- .../src/protocol/common.rs | 27 +- .../app-server-protocol/src/protocol/v2.rs | 14 + codex-rs/app-server-test-client/src/lib.rs | 53 ++- codex-rs/app-server/README.md | 36 +- .../app-server/src/codex_message_processor.rs | 179 ++++++++- .../app-server/tests/common/mcp_process.rs | 8 + codex-rs/app-server/tests/suite/v2/account.rs | 355 ++++++++++++++++++ 14 files changed, 802 insertions(+), 36 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9227a97d55..7c94419844 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1156,6 +1156,22 @@ "title": "ChatgptLoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodeLoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodeLoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8531542d55..91490ce26f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8569,6 +8569,22 @@ "title": "Chatgptv2::LoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { @@ -8650,6 +8666,36 @@ "title": "Chatgptv2::LoginAccountResponse", "type": "object" }, + { + "properties": { + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, { "properties": { "type": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 9d28f531af..0f31f5b3ec 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5383,6 +5383,22 @@ "title": "Chatgptv2::LoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { @@ -5464,6 +5480,36 @@ "title": "Chatgptv2::LoginAccountResponse", "type": "object" }, + { + "properties": { + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, { "properties": { "type": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json index ce6bdd4a3f..a933b71a83 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json @@ -37,6 +37,22 @@ "title": "Chatgptv2::LoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json index e2697ea44e..a800bffccd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json @@ -42,6 +42,36 @@ "title": "Chatgptv2::LoginAccountResponse", "type": "object" }, + { + "properties": { + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, { "properties": { "type": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts index ef668f9c1a..9fcb01f97c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptAuthTokens", +export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", /** * Access token (JWT) supplied by the client. * This token is used for backend API requests and email extraction. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts index cd79f6c83f..651f171e31 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts @@ -6,4 +6,12 @@ export type LoginAccountResponse = { "type": "apiKey", } | { "type": "chatgpt", /** * URL the client should open in a browser to initiate the OAuth flow. */ -authUrl: string, } | { "type": "chatgptAuthTokens", }; +authUrl: string, } | { "type": "chatgptDeviceCode", loginId: string, +/** + * URL the client should open in a browser to complete device code authorization. + */ +verificationUrl: string, +/** + * One-time code the user must enter after signing in. + */ +userCode: string, } | { "type": "chatgptAuthTokens", }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 6bd44562e0..30061c716e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1435,16 +1435,35 @@ mod tests { Ok(()) } + #[test] + fn serialize_account_login_chatgpt_device_code() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(4), + params: v2::LoginAccountParams::ChatgptDeviceCode, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 4, + "params": { + "type": "chatgptDeviceCode" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_account_logout() -> Result<()> { let request = ClientRequest::LogoutAccount { - request_id: RequestId::Integer(4), + request_id: RequestId::Integer(5), params: None, }; assert_eq!( json!({ "method": "account/logout", - "id": 4, + "id": 5, }), serde_json::to_value(&request)?, ); @@ -1454,7 +1473,7 @@ mod tests { #[test] fn serialize_account_login_chatgpt_auth_tokens() -> Result<()> { let request = ClientRequest::LoginAccount { - request_id: RequestId::Integer(5), + request_id: RequestId::Integer(6), params: v2::LoginAccountParams::ChatgptAuthTokens { access_token: "access-token".to_string(), chatgpt_account_id: "org-123".to_string(), @@ -1464,7 +1483,7 @@ mod tests { assert_eq!( json!({ "method": "account/login/start", - "id": 5, + "id": 6, "params": { "type": "chatgptAuthTokens", "accessToken": "access-token", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 930112eed9..9373c852d8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1589,6 +1589,9 @@ pub enum LoginAccountParams { #[serde(rename = "chatgpt")] #[ts(rename = "chatgpt")] Chatgpt, + #[serde(rename = "chatgptDeviceCode")] + #[ts(rename = "chatgptDeviceCode")] + ChatgptDeviceCode, /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. #[experimental("account/login/start.chatgptAuthTokens")] @@ -1626,6 +1629,17 @@ pub enum LoginAccountResponse { /// URL the client should open in a browser to initiate the OAuth flow. auth_url: String, }, + #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] + #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] + ChatgptDeviceCode { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to complete device code authorization. + verification_url: String, + /// One-time code the user must enter after signing in. + user_code: String, + }, #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] ChatgptAuthTokens {}, diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 82954d3cb6..dd548012a0 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -225,7 +225,11 @@ enum CliCommand { abort_on: Option, }, /// Trigger the ChatGPT login flow and wait for completion. - TestLogin, + TestLogin { + /// Use the device-code login flow instead of the browser callback flow. + #[arg(long, default_value_t = false)] + device_code: bool, + }, /// Fetch the current account rate limits from the Codex app-server. GetAccountRateLimits, /// List the available models from the Codex app-server. @@ -372,10 +376,10 @@ pub async fn run() -> Result<()> { ) .await } - CliCommand::TestLogin => { + CliCommand::TestLogin { device_code } => { ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?; let endpoint = resolve_endpoint(codex_bin, url)?; - test_login(&endpoint, &config_overrides).await + test_login(&endpoint, &config_overrides, device_code).await } CliCommand::GetAccountRateLimits => { ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?; @@ -1028,17 +1032,38 @@ async fn send_follow_up_v2( .await } -async fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { +async fn test_login( + endpoint: &Endpoint, + config_overrides: &[String], + device_code: bool, +) -> Result<()> { with_client("test-login", endpoint, config_overrides, |client| { let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); - let login_response = client.login_account_chatgpt()?; - println!("< account/login/start response: {login_response:?}"); - let LoginAccountResponse::Chatgpt { login_id, auth_url } = login_response else { - bail!("expected chatgpt login response"); + let login_response = if device_code { + client.login_account_chatgpt_device_code()? + } else { + client.login_account_chatgpt()? + }; + println!("< account/login/start response: {login_response:?}"); + let login_id = match login_response { + LoginAccountResponse::Chatgpt { login_id, auth_url } => { + println!("Open the following URL in your browser to continue:\n{auth_url}"); + login_id + } + LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + } => { + println!( + "Open the following URL and enter the code to continue:\n{verification_url}\n\nCode: {user_code}" + ); + login_id + } + _ => bail!("expected chatgpt login response"), }; - println!("Open the following URL in your browser to continue:\n{auth_url}"); let completion = client.wait_for_account_login_completion(&login_id)?; println!("< account/login/completed notification: {completion:?}"); @@ -1590,6 +1615,16 @@ impl CodexClient { self.send_request(request, request_id, "account/login/start") } + fn login_account_chatgpt_device_code(&mut self) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::LoginAccount { + request_id: request_id.clone(), + params: codex_app_server_protocol::LoginAccountParams::ChatgptDeviceCode, + }; + + self.send_request(request, request_id, "account/login/start") + } + fn get_account_rate_limits(&mut self) -> Result { let request_id = self.request_id(); let request = ClientRequest::GetAccountRateLimits { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 38cdacac79..4365cb8e29 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1309,14 +1309,14 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`. - **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. -- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"`; Codex persists tokens to disk and refreshes them automatically. +- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically. ### API Overview - `account/read` — fetch current account info; optionally refresh tokens. -- `account/login/start` — begin login (`apiKey`, `chatgpt`). +- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`). - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). -- `account/login/cancel` — cancel a pending ChatGPT login by `loginId`. +- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). @@ -1380,26 +1380,40 @@ Field notes: { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } ``` -### 4) Cancel a ChatGPT login +### 4) Log in with ChatGPT (device code flow) + +1. Start: + ```json + { "method": "account/login/start", "id": 4, "params": { "type": "chatgptDeviceCode" } } + { "id": 4, "result": { "type": "chatgptDeviceCode", "loginId": "", "verificationUrl": "https://auth.openai.com/codex/device", "userCode": "ABCD-1234" } } + ``` +2. Show `verificationUrl` and `userCode` to the user; the frontend owns the UX. +3. Wait for notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } + ``` + +### 5) Cancel a ChatGPT login ```json -{ "method": "account/login/cancel", "id": 4, "params": { "loginId": "" } } +{ "method": "account/login/cancel", "id": 5, "params": { "loginId": "" } } { "method": "account/login/completed", "params": { "loginId": "", "success": false, "error": "…" } } ``` -### 5) Logout +### 6) Logout ```json -{ "method": "account/logout", "id": 5 } -{ "id": 5, "result": {} } +{ "method": "account/logout", "id": 6 } +{ "id": 6, "result": {} } { "method": "account/updated", "params": { "authMode": null, "planType": null } } ``` -### 6) Rate limits (ChatGPT) +### 7) Rate limits (ChatGPT) ```json -{ "method": "account/rateLimits/read", "id": 6 } -{ "id": 6, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } +{ "method": "account/rateLimits/read", "id": 7 } +{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } { "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } ``` diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 875eb2b87f..f7e2916747 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -249,6 +249,8 @@ use codex_git_utils::git_diff_to_remote; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::auth::login_with_chatgpt_auth_tokens; +use codex_login::complete_device_code_login; +use codex_login::request_device_code; use codex_login::run_login_server; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; @@ -339,12 +341,39 @@ struct ThreadListFilters { search_term: Option, } -// Duration before a ChatGPT login attempt is abandoned. +// Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); -struct ActiveLogin { - shutdown_handle: ShutdownHandle, - login_id: Uuid, + +enum ActiveLogin { + Browser { + shutdown_handle: ShutdownHandle, + login_id: Uuid, + }, + DeviceCode { + cancel: CancellationToken, + login_id: Uuid, + }, +} + +impl ActiveLogin { + fn login_id(&self) -> Uuid { + match self { + ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => { + *login_id + } + } + } + + fn cancel(&self) { + match self { + ActiveLogin::Browser { + shutdown_handle, .. + } => shutdown_handle.shutdown(), + ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(), + } + } } #[derive(Clone, Copy, Debug)] @@ -365,7 +394,7 @@ enum ThreadShutdownResult { impl Drop for ActiveLogin { fn drop(&mut self) { - self.shutdown_handle.shutdown(); + self.cancel(); } } @@ -954,6 +983,9 @@ impl CodexMessageProcessor { LoginAccountParams::Chatgpt => { self.login_chatgpt_v2(request_id).await; } + LoginAccountParams::ChatgptDeviceCode => { + self.login_chatgpt_device_code_v2(request_id).await; + } LoginAccountParams::ChatgptAuthTokens { access_token, chatgpt_account_id, @@ -1074,7 +1106,7 @@ impl CodexMessageProcessor { }); } - Ok(LoginServerOptions { + let mut opts = LoginServerOptions { open_browser: false, ..LoginServerOptions::new( config.codex_home.clone(), @@ -1082,7 +1114,32 @@ impl CodexMessageProcessor { config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, ) - }) + }; + #[cfg(debug_assertions)] + if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) + && !issuer.trim().is_empty() + { + opts.issuer = issuer; + } + + Ok(opts) + } + + fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { + let is_not_found = err.kind() == std::io::ErrorKind::NotFound; + JSONRPCErrorError { + code: if is_not_found { + INVALID_REQUEST_ERROR_CODE + } else { + INTERNAL_ERROR_CODE + }, + message: if is_not_found { + err.to_string() + } else { + format!("failed to request device code: {err}") + }, + data: None, + } } async fn login_chatgpt_v2(&mut self, request_id: ConnectionRequestId) { @@ -1098,7 +1155,7 @@ impl CodexMessageProcessor { if let Some(existing) = guard.take() { drop(existing); } - *guard = Some(ActiveLogin { + *guard = Some(ActiveLogin::Browser { shutdown_handle: shutdown_handle.clone(), login_id, }); @@ -1168,7 +1225,7 @@ impl CodexMessageProcessor { // Clear the active login if it matches this attempt. It may have been replaced or cancelled. let mut guard = active_login.lock().await; - if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { *guard = None; } }); @@ -1194,12 +1251,114 @@ impl CodexMessageProcessor { } } + async fn login_chatgpt_device_code_v2(&mut self, request_id: ConnectionRequestId) { + match self.login_chatgpt_common().await { + Ok(opts) => match request_device_code(&opts).await { + Ok(device_code) => { + let login_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); + } + *guard = Some(ActiveLogin::DeviceCode { + cancel: cancel.clone(), + login_id, + }); + } + + let verification_url = device_code.verification_url.clone(); + let user_code = device_code.user_code.clone(); + let response = + codex_app_server_protocol::LoginAccountResponse::ChatgptDeviceCode { + login_id: login_id.to_string(), + verification_url, + user_code, + }; + self.outgoing.send_response(request_id, response).await; + + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let codex_home = self.config.codex_home.clone(); + let cli_overrides = self.current_cli_overrides(); + tokio::spawn(async move { + let (success, error_msg) = tokio::select! { + _ = cancel.cancelled() => { + (false, Some("Login was not completed".to_string())) + } + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + } + } + }; + + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_v2, + )) + .await; + + if success { + auth_manager.reload(); + replace_cloud_requirements_loader( + cloud_requirements.as_ref(), + auth_manager.clone(), + chatgpt_base_url, + codex_home, + ); + sync_default_client_residency_requirement( + &cli_overrides, + cloud_requirements.as_ref(), + ) + .await; + + let auth = auth_manager.auth_cached(); + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountUpdated( + payload_v2, + )) + .await; + } + + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + } + Err(err) => { + let error = Self::login_chatgpt_device_code_start_error(err); + self.outgoing.send_error(request_id, error).await; + } + }, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + async fn cancel_login_chatgpt_common( &mut self, login_id: Uuid, ) -> std::result::Result<(), CancelLoginError> { let mut guard = self.active_login.lock().await; - if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { if let Some(active) = guard.take() { drop(active); } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index fe21cdafff..0345028d88 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -840,6 +840,14 @@ impl McpProcess { self.send_request("account/login/start", Some(params)).await } + /// Send an `account/login/start` JSON-RPC request for ChatGPT device code login. + pub async fn send_login_account_chatgpt_device_code_request(&mut self) -> anyhow::Result { + let params = serde_json::json!({ + "type": "chatgptDeviceCode" + }); + self.send_request("account/login/start", Some(params)).await + } + /// Send an `account/login/cancel` JSON-RPC request. pub async fn send_cancel_login_account_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 514a6542a0..9458ae7f21 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -39,10 +39,14 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; // Helper to create a minimal config.toml for the app server #[derive(Default)] @@ -98,6 +102,58 @@ stream_max_retries = 0 std::fs::write(config_toml, contents) } +async fn mock_device_code_usercode(server: &MockServer, interval_seconds: u64) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_auth_id": "device-auth-123", + "user_code": "CODE-12345", + "interval": interval_seconds.to_string(), + }))) + .mount(server) + .await; +} + +async fn mock_device_code_usercode_failure(server: &MockServer, status: u16) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; +} + +async fn mock_device_code_token_success(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_code": "poll-code-321", + "code_challenge": "code-challenge-321", + "code_verifier": "code-verifier-321", + }))) + .mount(server) + .await; +} + +async fn mock_device_code_token_failure(server: &MockServer, status: u16) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/token")) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; +} + +async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) { + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id_token": id_token, + "access_token": "access-token-123", + "refresh_token": "refresh-token-123", + }))) + .mount(server) + .await; +} + #[tokio::test] async fn logout_account_removes_auth_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; @@ -912,6 +968,305 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { Ok(()) } +#[tokio::test] +async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + mock_device_code_usercode_failure(&mock_server, 404).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert!( + err.error + .message + .contains("device code login is not enabled"), + "unexpected error: {:?}", + err.error.message + ); + + let maybe_completed = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await; + assert!( + maybe_completed.is_err(), + "account/login/completed should not be emitted when device code start fails" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code start fails" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, 0).await; + mock_device_code_token_success(&mock_server).await; + let id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("device@example.com") + .plan_type("pro") + .chatgpt_account_id("org-device"), + )?; + mock_device_code_oauth_token(&mock_server, &id_token).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + } = login + else { + bail!("unexpected login response: {login:?}"); + }; + assert_eq!(verification_url, format!("{issuer}/codex/device")); + assert_eq!(user_code, "CODE-12345"); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, true); + assert_eq!(payload.error, None); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.auth_mode, Some(AuthMode::Chatgpt)); + assert_eq!(payload.plan_type, Some(AccountPlanType::Pro)); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should be created when device code login succeeds" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_failure_notifies_without_account_update() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, 0).await; + mock_device_code_token_failure(&mock_server, 500).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, false); + assert!( + payload + .error + .as_deref() + .is_some_and(|error| error.contains("device auth failed with status")), + "unexpected error: {:?}", + payload.error + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when device code login fails" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code login fails" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, 1).await; + mock_device_code_token_failure(&mock_server, 404).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + + let cancel_id = mcp + .send_cancel_login_account_request(CancelLoginAccountParams { + login_id: login_id.clone(), + }) + .await?; + let cancel_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), + ) + .await??; + let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?; + assert_eq!(cancel.status, CancelLoginAccountStatus::Canceled); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, false); + assert!( + payload.error.is_some(), + "expected a non-empty error on device code cancel" + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when device code login is cancelled" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code login is cancelled" + ); + Ok(()) +} + #[tokio::test] // Serialize tests that launch the login server since it binds to a fixed port. #[serial(login_port)] From 2e849703cd8a2eaebff094ef2a5108a1393d02ed Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 27 Mar 2026 09:41:47 +0100 Subject: [PATCH 011/185] chore: drop useless stuff (#15876) --- MODULE.bazel.lock | 3 - codex-rs/Cargo.lock | 52 -- codex-rs/Cargo.toml | 6 - codex-rs/package-manager/BUILD.bazel | 6 - codex-rs/package-manager/Cargo.toml | 27 - codex-rs/package-manager/README.md | 64 --- codex-rs/package-manager/src/archive.rs | 270 --------- codex-rs/package-manager/src/config.rs | 40 -- codex-rs/package-manager/src/error.rs | 54 -- codex-rs/package-manager/src/lib.rs | 17 - codex-rs/package-manager/src/manager.rs | 464 --------------- codex-rs/package-manager/src/package.rs | 69 --- codex-rs/package-manager/src/platform.rs | 48 -- codex-rs/package-manager/src/tests.rs | 700 ----------------------- 14 files changed, 1820 deletions(-) delete mode 100644 codex-rs/package-manager/BUILD.bazel delete mode 100644 codex-rs/package-manager/Cargo.toml delete mode 100644 codex-rs/package-manager/README.md delete mode 100644 codex-rs/package-manager/src/archive.rs delete mode 100644 codex-rs/package-manager/src/config.rs delete mode 100644 codex-rs/package-manager/src/error.rs delete mode 100644 codex-rs/package-manager/src/lib.rs delete mode 100644 codex-rs/package-manager/src/manager.rs delete mode 100644 codex-rs/package-manager/src/package.rs delete mode 100644 codex-rs/package-manager/src/platform.rs delete mode 100644 codex-rs/package-manager/src/tests.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7b3454a508..2333f48504 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -824,7 +824,6 @@ "fdeflate_0.3.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.7.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"name\":\"simd-adler32\",\"req\":\"^0.3.4\"}],\"features\":{}}", "fiat-crypto_0.2.9": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "filedescriptor_0.8.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"winuser\",\"handleapi\",\"fileapi\",\"namedpipeapi\",\"processthreadsapi\",\"winsock2\",\"processenv\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "filetime_0.2.27": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.27\",\"target\":\"cfg(unix)\"},{\"name\":\"libredox\",\"req\":\"^0.1.0\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{}}", "find-crate_0.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^0.11\"},{\"name\":\"toml\",\"req\":\"^0.5.2\"}],\"features\":{}}", "find-msvc-tools_0.1.9": "{\"dependencies\":[],\"features\":{}}", "findshlibs_0.10.2": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.67\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"name\":\"libc\",\"req\":\"^0.2.104\"},{\"features\":[\"psapi\",\"memoryapi\",\"libloaderapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{}}", @@ -1326,7 +1325,6 @@ "system-configuration-sys_0.6.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.149\"}],\"features\":{}}", "system-configuration_0.6.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", - "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "tempfile_3.24.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.3\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", "temporal_rs_0.1.2": "{\"dependencies\":[{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"unstable\",\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", @@ -1542,7 +1540,6 @@ "x11rb_0.13.2": "{\"dependencies\":[{\"name\":\"as-raw-xcb-connection\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"gethostname\",\"req\":\"^1.0\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.19\"},{\"kind\":\"dev\",\"name\":\"polling\",\"req\":\"^3.4\"},{\"name\":\"raw-window-handle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"std\",\"event\",\"fs\",\"net\",\"system\"],\"name\":\"rustix\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"x11rb-protocol\",\"req\":\"^0.13.2\"},{\"name\":\"xcursor\",\"optional\":true,\"req\":\"^0.3.7\"}],\"features\":{\"all-extensions\":[\"x11rb-protocol/all-extensions\",\"composite\",\"damage\",\"dbe\",\"dpms\",\"dri2\",\"dri3\",\"glx\",\"present\",\"randr\",\"record\",\"render\",\"res\",\"screensaver\",\"shape\",\"shm\",\"sync\",\"xevie\",\"xf86dri\",\"xf86vidmode\",\"xfixes\",\"xinerama\",\"xinput\",\"xkb\",\"xprint\",\"xselinux\",\"xtest\",\"xv\",\"xvmc\"],\"allow-unsafe-code\":[\"libc\",\"as-raw-xcb-connection\"],\"composite\":[\"x11rb-protocol/composite\",\"xfixes\"],\"cursor\":[\"render\",\"resource_manager\",\"xcursor\"],\"damage\":[\"x11rb-protocol/damage\",\"xfixes\"],\"dbe\":[\"x11rb-protocol/dbe\"],\"dl-libxcb\":[\"allow-unsafe-code\",\"libloading\",\"once_cell\"],\"dpms\":[\"x11rb-protocol/dpms\"],\"dri2\":[\"x11rb-protocol/dri2\"],\"dri3\":[\"x11rb-protocol/dri3\"],\"extra-traits\":[\"x11rb-protocol/extra-traits\"],\"glx\":[\"x11rb-protocol/glx\"],\"image\":[],\"present\":[\"x11rb-protocol/present\",\"randr\",\"xfixes\",\"sync\"],\"randr\":[\"x11rb-protocol/randr\",\"render\"],\"record\":[\"x11rb-protocol/record\"],\"render\":[\"x11rb-protocol/render\"],\"request-parsing\":[\"x11rb-protocol/request-parsing\"],\"res\":[\"x11rb-protocol/res\"],\"resource_manager\":[\"x11rb-protocol/resource_manager\"],\"screensaver\":[\"x11rb-protocol/screensaver\"],\"shape\":[\"x11rb-protocol/shape\"],\"shm\":[\"x11rb-protocol/shm\"],\"sync\":[\"x11rb-protocol/sync\"],\"xevie\":[\"x11rb-protocol/xevie\"],\"xf86dri\":[\"x11rb-protocol/xf86dri\"],\"xf86vidmode\":[\"x11rb-protocol/xf86vidmode\"],\"xfixes\":[\"x11rb-protocol/xfixes\",\"render\",\"shape\"],\"xinerama\":[\"x11rb-protocol/xinerama\"],\"xinput\":[\"x11rb-protocol/xinput\",\"xfixes\"],\"xkb\":[\"x11rb-protocol/xkb\"],\"xprint\":[\"x11rb-protocol/xprint\"],\"xselinux\":[\"x11rb-protocol/xselinux\"],\"xtest\":[\"x11rb-protocol/xtest\"],\"xv\":[\"x11rb-protocol/xv\",\"shm\"],\"xvmc\":[\"x11rb-protocol/xvmc\",\"xv\"]}}", "x25519-dalek_2.0.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"curve25519-dalek/alloc\",\"serde?/alloc\",\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"getrandom\":[\"rand_core/getrandom\"],\"precomputed-tables\":[\"curve25519-dalek/precomputed-tables\"],\"reusable_secrets\":[],\"serde\":[\"dep:serde\",\"curve25519-dalek/serde\"],\"static_secrets\":[],\"zeroize\":[\"dep:zeroize\",\"curve25519-dalek/zeroize\"]}}", "x509-parser_0.18.1": "{\"dependencies\":[{\"features\":[\"datetime\"],\"name\":\"asn1-rs\",\"req\":\"^0.7.0\"},{\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"data-encoding\",\"req\":\"^2.2.1\"},{\"features\":[\"bigint\"],\"name\":\"der-parser\",\"req\":\"^10.0\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"features\":[\"crypto\",\"x509\",\"x962\"],\"name\":\"oid-registry\",\"req\":\"^0.8.1\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.12\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.35\"}],\"features\":{\"default\":[],\"validate\":[],\"verify\":[\"ring\"],\"verify-aws\":[\"aws-lc-rs\"]}}", - "xattr_1.6.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.150\",\"target\":\"cfg(any(target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^1.0.0\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\", target_os = \\\"macos\\\", target_os = \\\"hurd\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"default\":[\"unsupported\"],\"unsupported\":[]}}", "xdg-home_1.3.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "xz2_0.1.7": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.1.26\"},{\"name\":\"lzma-sys\",\"req\":\"^0.1.18\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"tokio-core\",\"req\":\"^0.1.17\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.12\"}],\"features\":{\"static\":[\"lzma-sys/static\"],\"tokio\":[\"tokio-io\",\"futures\"]}}", "yaml-rust_0.4.5": "{\"dependencies\":[{\"name\":\"linked-hash-map\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c5846fbe19..17d5c35e83 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2358,26 +2358,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "codex-package-manager" -version = "0.0.0" -dependencies = [ - "fd-lock", - "flate2", - "pretty_assertions", - "reqwest", - "serde", - "serde_json", - "sha2", - "tar", - "tempfile", - "thiserror 2.0.18", - "tokio", - "url", - "wiremock", - "zip", -] - [[package]] name = "codex-plugin" version = "0.0.0" @@ -4288,17 +4268,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-crate" version = "0.6.3" @@ -9873,17 +9842,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tar" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.24.0" @@ -12010,16 +11968,6 @@ dependencies = [ "time", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.3", -] - [[package]] name = "xdg-home" version = "1.3.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f05355e393..d844290146 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -81,7 +81,6 @@ members = [ "state", "terminal-detection", "codex-experimental-api-macros", - "package-manager", "plugin", ] resolver = "2" @@ -102,7 +101,6 @@ codex-ansi-escape = { path = "ansi-escape" } codex-analytics = { path = "analytics" } codex-api = { path = "codex-api" } codex-code-mode = { path = "code-mode" } -codex-package-manager = { path = "package-manager" } codex-app-server = { path = "app-server" } codex-app-server-client = { path = "app-server-client" } codex-app-server-protocol = { path = "app-server-protocol" } @@ -213,11 +211,9 @@ dirs = "6" dotenvy = "0.15.7" dunce = "1.0.4" encoding_rs = "0.8.35" -fd-lock = "4.0.4" env-flags = "0.1.1" env_logger = "0.11.9" eventsource-stream = "0.2.3" -flate2 = "1.1.4" futures = { version = "0.3", default-features = false } gethostname = "1.1.0" globset = "0.4" @@ -309,7 +305,6 @@ supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" tempfile = "3.23.0" -tar = "0.4.45" test-log = "0.2.19" textwrap = "0.16.2" thiserror = "2.0.17" @@ -395,7 +390,6 @@ unwrap_used = "deny" ignored = [ "icu_provider", "openssl-sys", - "codex-package-manager", "codex-utils-readiness", "codex-utils-template", "codex-v8-poc", diff --git a/codex-rs/package-manager/BUILD.bazel b/codex-rs/package-manager/BUILD.bazel deleted file mode 100644 index bbbc725cbf..0000000000 --- a/codex-rs/package-manager/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "package-manager", - crate_name = "codex_package_manager", -) diff --git a/codex-rs/package-manager/Cargo.toml b/codex-rs/package-manager/Cargo.toml deleted file mode 100644 index 3a138a80be..0000000000 --- a/codex-rs/package-manager/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "codex-package-manager" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -fd-lock = { workspace = true } -flate2 = { workspace = true } -reqwest = { workspace = true, features = ["json", "stream"] } -serde = { workspace = true, features = ["derive"] } -sha2 = { workspace = true } -tar = { workspace = true } -tempfile = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } -url = { workspace = true } -zip = { workspace = true } - -[lints] -workspace = true - -[dev-dependencies] -pretty_assertions = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["fs", "macros", "rt", "rt-multi-thread"] } -wiremock = { workspace = true } diff --git a/codex-rs/package-manager/README.md b/codex-rs/package-manager/README.md deleted file mode 100644 index c761bcdd65..0000000000 --- a/codex-rs/package-manager/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# codex-package-manager - -`codex-package-manager` is the shared installer used for versioned runtime bundles and other cached artifacts in `codex-rs`. - -It owns the generic parts of package installation: - -- current-platform detection -- manifest and archive fetches -- checksum and archive-size validation -- archive extraction for `.zip` and `.tar.gz` -- staging and promotion into a versioned cache directory -- cross-process install locking - -Package-specific code stays behind the `ManagedPackage` trait. - -## Model - -The package manager is intentionally small: - -1. A `ManagedPackage` implementation describes how to fetch a manifest, choose an archive for a `PackagePlatform`, and load a validated installed package from disk. -2. `PackageManager::resolve_cached()` returns a cached install for the current platform if `load_installed()` succeeds and the version matches. -3. `PackageManager::ensure_installed()` acquires a per-install lock, downloads the archive into a staging directory, extracts it, validates the staged package, and promotes it into the cache. - -The default cache root is: - -```text -/ -``` - -Callers can override that root with `PackageManagerConfig::with_cache_root(...)`. - -## ManagedPackage Contract - -The trait is small, but the invariants matter: - -- `install_dir()` should be unique per package version and platform. If two versions or two platforms share a directory, promotion and cleanup become unsafe. -- `load_installed()` must fully validate the installed package, not just deserialize a manifest. `resolve_cached()` trusts a successful load as a valid cache hit. -- The default `detect_extracted_root()` looks for `manifest.json` at the extraction root or inside a single top-level directory. Override it if your package layout differs. -- `archive_url()` should be derived from manifest data, not recomputed from unrelated caller state, so manifest selection and download stay aligned. - -## Consumer Guidance - -- If your feature can install on demand, do not gate feature registration on a preinstalled-cache check alone. `resolve_cached()` only answers "is it already present?" while `ensure_installed()` is the bootstrap path. -- Keep cache-root overrides inside your manager/config surface. Separate helpers that reconstruct install paths can drift from `PackageManagerConfig`. -- Prefer surfacing package-specific validation failures from `load_installed()` when debugging. The generic manager treats failed cache loads as cache misses today. - -## Security and Extraction Rules - -- `.zip` extraction rejects entries that escape the extraction root and preserves Unix executable bits when the archive carries them. -- `.tar.gz` extraction rejects symlinks, hard links, sparse files, device files, and FIFOs. Only regular files and directories are promoted. -- The archive SHA-256 is always verified, and `size_bytes` is enforced when present in the manifest. - -## Extending It - -Typical usage looks like this: - -```rust,ignore -let config = PackageManagerConfig::new(codex_home, MyPackage::new(...)); -let manager = PackageManager::new(config); - -let package = manager.ensure_installed().await?; -``` - -In practice, most packages should expose their own small wrapper config/manager types over the generic crate so the rest of the codebase does not depend on `ManagedPackage` details directly. diff --git a/codex-rs/package-manager/src/archive.rs b/codex-rs/package-manager/src/archive.rs deleted file mode 100644 index 80de96c84d..0000000000 --- a/codex-rs/package-manager/src/archive.rs +++ /dev/null @@ -1,270 +0,0 @@ -use crate::PackageManagerError; -use flate2::read::GzDecoder; -use sha2::Digest; -use sha2::Sha256; -use std::fs::File; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::Component; -use std::path::Path; -use std::path::PathBuf; -use tar::Archive; -use zip::ZipArchive; - -/// Archive metadata for a platform entry in a release manifest. -#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] -pub struct PackageReleaseArchive { - /// Archive file name relative to the package release location. - pub archive: String, - /// Expected SHA-256 of the downloaded archive body. - pub sha256: String, - /// Archive format used by the download. - pub format: ArchiveFormat, - /// Expected archive length in bytes, when the manifest provides it. - pub size_bytes: Option, -} - -/// Archive formats supported by the generic extractor. -#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] -pub enum ArchiveFormat { - /// A `.zip` archive. - #[serde(rename = "zip")] - Zip, - /// A `.tar.gz` archive. - #[serde(rename = "tar.gz")] - TarGz, -} - -/// Detects a package root with a `manifest.json` in an extraction directory. -pub(crate) fn detect_single_package_root( - extraction_root: &Path, -) -> Result { - let direct_manifest = extraction_root.join("manifest.json"); - if direct_manifest.exists() { - return Ok(extraction_root.to_path_buf()); - } - - let mut directory_candidates = Vec::new(); - for entry in std::fs::read_dir(extraction_root).map_err(|source| PackageManagerError::Io { - context: format!("failed to read {}", extraction_root.display()), - source, - })? { - let entry = entry.map_err(|source| PackageManagerError::Io { - context: format!("failed to read entry in {}", extraction_root.display()), - source, - })?; - let path = entry.path(); - if path.is_dir() { - directory_candidates.push(path); - } - } - - if directory_candidates.len() == 1 { - let candidate = &directory_candidates[0]; - if candidate.join("manifest.json").exists() { - return Ok(candidate.clone()); - } - } - - Err(PackageManagerError::MissingPackageRoot( - extraction_root.to_path_buf(), - )) -} - -pub(crate) fn verify_archive_size( - bytes: &[u8], - expected: Option, -) -> Result<(), PackageManagerError> { - let Some(expected) = expected else { - return Ok(()); - }; - let actual = bytes.len() as u64; - if actual == expected { - return Ok(()); - } - Err(PackageManagerError::UnexpectedArchiveSize { expected, actual }) -} - -pub(crate) fn verify_sha256(bytes: &[u8], expected: &str) -> Result<(), PackageManagerError> { - let actual = format!("{:x}", Sha256::digest(bytes)); - if actual == expected.to_ascii_lowercase() { - return Ok(()); - } - Err(PackageManagerError::ChecksumMismatch { - expected: expected.to_string(), - actual, - }) -} - -pub(crate) fn extract_archive( - archive_path: &Path, - destination: &Path, - format: ArchiveFormat, -) -> Result<(), PackageManagerError> { - match format { - ArchiveFormat::Zip => extract_zip_archive(archive_path, destination), - ArchiveFormat::TarGz => extract_tar_gz_archive(archive_path, destination), - } -} - -fn extract_zip_archive(archive_path: &Path, destination: &Path) -> Result<(), PackageManagerError> { - let file = File::open(archive_path).map_err(|source| PackageManagerError::Io { - context: format!("failed to open {}", archive_path.display()), - source, - })?; - let mut archive = ZipArchive::new(file) - .map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?; - for index in 0..archive.len() { - let mut entry = archive - .by_index(index) - .map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?; - let Some(relative_path) = entry.enclosed_name() else { - return Err(PackageManagerError::ArchiveExtraction(format!( - "zip entry `{}` escapes extraction root", - entry.name() - ))); - }; - let output_path = destination.join(relative_path); - if entry.is_dir() { - std::fs::create_dir_all(&output_path).map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", output_path.display()), - source, - })?; - continue; - } - if let Some(parent) = output_path.parent() { - std::fs::create_dir_all(parent).map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", parent.display()), - source, - })?; - } - let mut output = File::create(&output_path).map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", output_path.display()), - source, - })?; - std::io::copy(&mut entry, &mut output).map_err(|source| PackageManagerError::Io { - context: format!("failed to write {}", output_path.display()), - source, - })?; - apply_zip_permissions(&entry, &output_path)?; - } - Ok(()) -} - -#[cfg(unix)] -fn apply_zip_permissions( - entry: &zip::read::ZipFile<'_>, - output_path: &Path, -) -> Result<(), PackageManagerError> { - let Some(mode) = entry.unix_mode() else { - return Ok(()); - }; - std::fs::set_permissions(output_path, std::fs::Permissions::from_mode(mode)).map_err(|source| { - PackageManagerError::Io { - context: format!("failed to set permissions on {}", output_path.display()), - source, - } - }) -} - -#[cfg(not(unix))] -fn apply_zip_permissions( - _entry: &zip::read::ZipFile<'_>, - _output_path: &Path, -) -> Result<(), PackageManagerError> { - Ok(()) -} - -fn extract_tar_gz_archive( - archive_path: &Path, - destination: &Path, -) -> Result<(), PackageManagerError> { - let file = File::open(archive_path).map_err(|source| PackageManagerError::Io { - context: format!("failed to open {}", archive_path.display()), - source, - })?; - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); - for entry in archive - .entries() - .map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))? - { - let mut entry = - entry.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?; - let path = entry - .path() - .map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?; - let output_path = safe_extract_path(destination, path.as_ref())?; - let entry_type = entry.header().entry_type(); - - if entry_type.is_symlink() - || entry_type.is_hard_link() - || entry_type.is_block_special() - || entry_type.is_character_special() - || entry_type.is_fifo() - || entry_type.is_gnu_sparse() - { - return Err(PackageManagerError::ArchiveExtraction(format!( - "tar entry `{}` has unsupported type", - path.display() - ))); - } - - if entry_type.is_pax_global_extensions() - || entry_type.is_pax_local_extensions() - || entry_type.is_gnu_longname() - || entry_type.is_gnu_longlink() - { - continue; - } - - if entry_type.is_dir() { - std::fs::create_dir_all(&output_path).map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", output_path.display()), - source, - })?; - continue; - } - - if !entry_type.is_file() && !entry_type.is_contiguous() { - return Err(PackageManagerError::ArchiveExtraction(format!( - "tar entry `{}` has unsupported type", - path.display() - ))); - } - - if let Some(parent) = output_path.parent() { - std::fs::create_dir_all(parent).map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", parent.display()), - source, - })?; - } - entry - .unpack(&output_path) - .map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?; - } - Ok(()) -} - -fn safe_extract_path(root: &Path, relative_path: &Path) -> Result { - let mut clean_relative = PathBuf::new(); - for component in relative_path.components() { - match component { - Component::Normal(segment) => clean_relative.push(segment), - Component::CurDir => {} - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err(PackageManagerError::ArchiveExtraction(format!( - "entry `{}` escapes extraction root", - relative_path.display() - ))); - } - } - } - - if clean_relative.as_os_str().is_empty() { - return Err(PackageManagerError::ArchiveExtraction( - "archive entry had an empty path".to_string(), - )); - } - Ok(root.join(clean_relative)) -} diff --git a/codex-rs/package-manager/src/config.rs b/codex-rs/package-manager/src/config.rs deleted file mode 100644 index 819c5f6ddf..0000000000 --- a/codex-rs/package-manager/src/config.rs +++ /dev/null @@ -1,40 +0,0 @@ -use crate::ManagedPackage; -use std::path::PathBuf; - -/// Immutable configuration for a [`crate::PackageManager`] instance. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PackageManagerConfig

{ - pub(crate) codex_home: PathBuf, - pub(crate) package: P, - cache_root: Option, -} - -impl

PackageManagerConfig

{ - /// Creates a config rooted at the provided Codex home directory. - pub fn new(codex_home: PathBuf, package: P) -> Self { - Self { - codex_home, - package, - cache_root: None, - } - } - - /// Overrides the package cache root instead of deriving it from `codex_home`. - pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self { - self.cache_root = Some(cache_root); - self - } -} - -impl PackageManagerConfig

{ - /// Returns the effective cache root for the package. - pub fn cache_root(&self) -> PathBuf { - self.cache_root.clone().unwrap_or_else(|| { - self.codex_home.join( - self.package - .default_cache_root_relative() - .replace('/', std::path::MAIN_SEPARATOR_STR), - ) - }) - } -} diff --git a/codex-rs/package-manager/src/error.rs b/codex-rs/package-manager/src/error.rs deleted file mode 100644 index 13883ff715..0000000000 --- a/codex-rs/package-manager/src/error.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::path::PathBuf; -use thiserror::Error; - -/// Errors returned by the generic package manager. -#[derive(Debug, Error)] -pub enum PackageManagerError { - /// The current machine OS/architecture pair is not supported by the package. - #[error("unsupported platform: {os}-{arch}")] - UnsupportedPlatform { os: String, arch: String }, - - /// The configured release base URL could not be joined with a package-specific path. - #[error("invalid release base url")] - InvalidBaseUrl(#[source] url::ParseError), - - /// An HTTP request failed while fetching the manifest or archive. - #[error("{context}")] - Http { - context: String, - #[source] - source: reqwest::Error, - }, - - /// A filesystem operation failed while reading, staging, or promoting a package. - #[error("{context}")] - Io { - context: String, - #[source] - source: std::io::Error, - }, - - /// The release manifest did not contain an archive for the current platform. - #[error("missing platform entry `{0}` in release manifest")] - MissingPlatform(String), - - /// The release manifest or installed package reported a different version than requested. - #[error("unexpected package version: expected `{expected}`, got `{actual}`")] - UnexpectedPackageVersion { expected: String, actual: String }, - - /// The downloaded archive length did not match the manifest metadata. - #[error("unexpected archive size: expected `{expected}`, got `{actual}`")] - UnexpectedArchiveSize { expected: u64, actual: u64 }, - - /// The downloaded archive checksum did not match the manifest metadata. - #[error("checksum mismatch: expected `{expected}`, got `{actual}`")] - ChecksumMismatch { expected: String, actual: String }, - - /// Archive extraction failed or the archive contents violated extraction rules. - #[error("archive extraction failed: {0}")] - ArchiveExtraction(String), - - /// The extracted archive layout did not contain a detectable package root. - #[error("archive did not contain a package root with manifest.json under {0}")] - MissingPackageRoot(PathBuf), -} diff --git a/codex-rs/package-manager/src/lib.rs b/codex-rs/package-manager/src/lib.rs deleted file mode 100644 index 5491d28af8..0000000000 --- a/codex-rs/package-manager/src/lib.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod archive; -mod config; -mod error; -mod manager; -mod package; -mod platform; - -#[cfg(test)] -mod tests; - -pub use archive::ArchiveFormat; -pub use archive::PackageReleaseArchive; -pub use config::PackageManagerConfig; -pub use error::PackageManagerError; -pub use manager::PackageManager; -pub use package::ManagedPackage; -pub use platform::PackagePlatform; diff --git a/codex-rs/package-manager/src/manager.rs b/codex-rs/package-manager/src/manager.rs deleted file mode 100644 index 7b9aa193e4..0000000000 --- a/codex-rs/package-manager/src/manager.rs +++ /dev/null @@ -1,464 +0,0 @@ -use crate::ManagedPackage; -use crate::PackageManagerConfig; -use crate::PackageManagerError; -use crate::PackagePlatform; -use crate::archive::extract_archive; -use crate::archive::verify_archive_size; -use crate::archive::verify_sha256; -use fd_lock::RwLock as FileRwLock; -use reqwest::Client; -use std::fs::OpenOptions; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use tempfile::tempdir_in; -use tokio::fs; -use tokio::time::sleep; -use url::Url; - -const INSTALL_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(50); - -/// Fetches and installs a versioned package into a shared cache directory. -#[derive(Clone, Debug)] -pub struct PackageManager

{ - client: Client, - config: PackageManagerConfig

, -} - -impl

PackageManager

{ - /// Creates a manager with a default `reqwest` client. - pub fn new(config: PackageManagerConfig

) -> Self { - Self { - client: Client::new(), - config, - } - } - - /// Creates a manager with a caller-provided HTTP client. - pub fn with_client(config: PackageManagerConfig

, client: Client) -> Self { - Self { client, config } - } -} - -impl PackageManager

{ - /// Resolves a valid cached install for the current platform, if one exists. - pub async fn resolve_cached(&self) -> Result, P::Error> { - let platform = PackagePlatform::detect_current().map_err(P::Error::from)?; - let install_dir = self - .config - .package - .install_dir(&self.config.cache_root(), platform); - self.resolve_cached_at(platform, install_dir).await - } - - /// Ensures the requested package is installed for the current platform. - pub async fn ensure_installed(&self) -> Result { - // Fast path: most calls should resolve an already validated cache entry - // without touching the network or the install lock. - if let Some(package) = self.resolve_cached().await? { - return Ok(package); - } - - let platform = PackagePlatform::detect_current().map_err(P::Error::from)?; - let cache_root = self.config.cache_root(); - let install_dir = self.config.package.install_dir(&cache_root, platform); - if let Some(package) = self - .resolve_cached_at(platform, install_dir.clone()) - .await? - { - return Ok(package); - } - - if let Some(parent) = install_dir.parent() { - fs::create_dir_all(parent) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", parent.display()), - source, - }) - .map_err(P::Error::from)?; - } - - let lock_path = install_dir.with_extension("lock"); - let lock_file = OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(&lock_path) - .map_err(|source| PackageManagerError::Io { - context: format!("failed to open {}", lock_path.display()), - source, - }) - .map_err(P::Error::from)?; - let mut install_lock = FileRwLock::new(lock_file); - let _install_guard = loop { - match install_lock.try_write() { - Ok(guard) => break guard, - Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => { - sleep(INSTALL_LOCK_POLL_INTERVAL).await; - } - Err(source) => { - return Err(PackageManagerError::Io { - context: format!("failed to lock {}", lock_path.display()), - source, - } - .into()); - } - } - }; - - // Another process may have finished the install while we were waiting - // on the lock, so re-check before doing any download or extraction work. - if let Some(package) = self - .resolve_cached_at(platform, install_dir.clone()) - .await? - { - return Ok(package); - } - - let manifest = self.fetch_release_manifest().await?; - if self.config.package.release_version(&manifest) != self.config.package.version() { - return Err(PackageManagerError::UnexpectedPackageVersion { - expected: self.config.package.version().to_string(), - actual: self.config.package.release_version(&manifest).to_string(), - } - .into()); - } - - fs::create_dir_all(&cache_root) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", cache_root.display()), - source, - }) - .map_err(P::Error::from)?; - let staging_root = cache_root.join(".staging"); - fs::create_dir_all(&staging_root) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", staging_root.display()), - source, - }) - .map_err(P::Error::from)?; - - // Everything below happens in a disposable staging area until the - // extracted package has passed package-specific validation. - let platform_archive = self.config.package.platform_archive(&manifest, platform)?; - let archive_url = self - .config - .package - .archive_url(&platform_archive) - .map_err(P::Error::from)?; - let archive_bytes = self.download_bytes(&archive_url).await?; - verify_archive_size(&archive_bytes, platform_archive.size_bytes).map_err(P::Error::from)?; - verify_sha256(&archive_bytes, &platform_archive.sha256).map_err(P::Error::from)?; - - let staging_dir = tempdir_in(&staging_root) - .map_err(|source| PackageManagerError::Io { - context: format!( - "failed to create staging directory in {}", - staging_root.display() - ), - source, - }) - .map_err(P::Error::from)?; - let archive_path = staging_dir.path().join(&platform_archive.archive); - fs::write(&archive_path, &archive_bytes) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to write {}", archive_path.display()), - source, - }) - .map_err(P::Error::from)?; - let extraction_root = staging_dir.path().join("extract"); - fs::create_dir_all(&extraction_root) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", extraction_root.display()), - source, - }) - .map_err(P::Error::from)?; - - extract_archive(&archive_path, &extraction_root, platform_archive.format) - .map_err(P::Error::from)?; - let extracted_root = self - .config - .package - .detect_extracted_root(&extraction_root)?; - let package = self - .config - .package - .load_installed(extracted_root.clone(), platform)?; - if self.config.package.installed_version(&package) != self.config.package.version() { - return Err(PackageManagerError::UnexpectedPackageVersion { - expected: self.config.package.version().to_string(), - actual: self.config.package.installed_version(&package).to_string(), - } - .into()); - } - - if let Some(parent) = install_dir.parent() { - fs::create_dir_all(parent) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to create {}", parent.display()), - source, - }) - .map_err(P::Error::from)?; - } - - // Promotion is intentionally two-phase: move the old install aside, - // then attempt an atomic rename of the staged tree into place. - // If promotion fails, restore the previous install before returning. - let replaced_install_dir = quarantine_existing_install(&install_dir) - .await - .map_err(P::Error::from)?; - let promotion = promote_staged_install(&extracted_root, &install_dir).await; - if let Err(error) = promotion { - // If another process won the race after we staged our copy, prefer - // the now-installed cache entry and clean up our quarantined copy. - if matches!( - &error, - PackageManagerError::Io { source, .. } - if matches!( - source.kind(), - std::io::ErrorKind::AlreadyExists - | std::io::ErrorKind::DirectoryNotEmpty - ) - ) && let Some(package) = self - .resolve_cached_at(platform, install_dir.clone()) - .await? - { - if let Some(replaced_install_dir) = replaced_install_dir { - let _ = fs::remove_dir_all(replaced_install_dir).await; - } - return Ok(package); - } - - restore_quarantined_install(&install_dir, replaced_install_dir.as_deref(), &error) - .await - .map_err(P::Error::from)?; - return Err(error.into()); - } - - // Validate from the final install path before deleting the quarantined - // previous install. Some packages may only fully validate once the - // promoted tree is in place at its real cache location. - let package = match self - .config - .package - .load_installed(install_dir.clone(), platform) - { - Ok(package) => package, - Err(error) => { - if let Some(replaced_install_dir) = replaced_install_dir.as_deref() { - // Final validation failed after promotion, so discard the - // broken install and restore the last known-good copy. - if fs::try_exists(&install_dir) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to read {}", install_dir.display()), - source, - }) - .map_err(P::Error::from)? - { - fs::remove_dir_all(&install_dir) - .await - .map_err(|source| PackageManagerError::Io { - context: format!( - "failed to remove invalid install {} after final validation failed", - install_dir.display() - ), - source, - }) - .map_err(P::Error::from)?; - } - fs::rename(replaced_install_dir, &install_dir) - .await - .map_err(|source| PackageManagerError::Io { - context: format!( - "failed to restore {} from {} after final validation failed", - install_dir.display(), - replaced_install_dir.display() - ), - source, - }) - .map_err(P::Error::from)?; - } - return Err(error); - } - }; - - if let Some(replaced_install_dir) = replaced_install_dir { - let _ = fs::remove_dir_all(replaced_install_dir).await; - } - - Ok(package) - } - - async fn resolve_cached_at( - &self, - platform: PackagePlatform, - install_dir: PathBuf, - ) -> Result, P::Error> { - if !fs::try_exists(&install_dir) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to read {}", install_dir.display()), - source, - }) - .map_err(P::Error::from)? - { - return Ok(None); - } - - let package = match self.config.package.load_installed(install_dir, platform) { - Ok(package) => package, - Err(_) => return Ok(None), - }; - if self.config.package.installed_version(&package) != self.config.package.version() { - return Ok(None); - } - Ok(Some(package)) - } - - async fn fetch_release_manifest(&self) -> Result { - let manifest_url = self.config.package.manifest_url().map_err(P::Error::from)?; - let response = self - .client - .get(manifest_url.clone()) - .send() - .await - .map_err(|source| PackageManagerError::Http { - context: format!("failed to fetch {manifest_url}"), - source, - }) - .map_err(P::Error::from)? - .error_for_status() - .map_err(|source| PackageManagerError::Http { - context: format!("manifest request failed for {manifest_url}"), - source, - }) - .map_err(P::Error::from)?; - - response - .json::() - .await - .map_err(|source| PackageManagerError::Http { - context: format!("failed to decode manifest from {manifest_url}"), - source, - }) - .map_err(P::Error::from) - } - - async fn download_bytes(&self, url: &Url) -> Result, P::Error> { - let response = self - .client - .get(url.clone()) - .send() - .await - .map_err(|source| PackageManagerError::Http { - context: format!("failed to download {url}"), - source, - }) - .map_err(P::Error::from)? - .error_for_status() - .map_err(|source| PackageManagerError::Http { - context: format!("archive request failed for {url}"), - source, - }) - .map_err(P::Error::from)?; - let bytes = response - .bytes() - .await - .map_err(|source| PackageManagerError::Http { - context: format!("failed to read response body for {url}"), - source, - }) - .map_err(P::Error::from)?; - Ok(bytes.to_vec()) - } -} - -pub(crate) async fn quarantine_existing_install( - install_dir: &Path, -) -> Result, PackageManagerError> { - if !fs::try_exists(install_dir) - .await - .map_err(|source| PackageManagerError::Io { - context: format!("failed to read {}", install_dir.display()), - source, - })? - { - return Ok(None); - } - - let install_name = install_dir.file_name().ok_or_else(|| { - PackageManagerError::ArchiveExtraction(format!( - "install path `{}` has no terminal component", - install_dir.display() - )) - })?; - let install_name = install_name.to_string_lossy(); - let mut suffix = 0u32; - loop { - let quarantined_path = install_dir.with_file_name(format!( - ".{install_name}.replaced-{}-{suffix}", - std::process::id() - )); - match fs::rename(install_dir, &quarantined_path).await { - Ok(()) => return Ok(Some(quarantined_path)), - Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => { - suffix += 1; - } - Err(source) => { - return Err(PackageManagerError::Io { - context: format!( - "failed to quarantine {} to {}", - install_dir.display(), - quarantined_path.display() - ), - source, - }); - } - } - } -} - -pub(crate) async fn promote_staged_install( - extracted_root: &Path, - install_dir: &Path, -) -> Result<(), PackageManagerError> { - fs::rename(extracted_root, install_dir) - .await - .map_err(|source| PackageManagerError::Io { - context: format!( - "failed to move {} to {}", - extracted_root.display(), - install_dir.display() - ), - source, - }) -} - -pub(crate) async fn restore_quarantined_install( - install_dir: &Path, - quarantined_install_dir: Option<&Path>, - promotion_error: &PackageManagerError, -) -> Result<(), PackageManagerError> { - let Some(quarantined_install_dir) = quarantined_install_dir else { - return Ok(()); - }; - - fs::rename(quarantined_install_dir, install_dir) - .await - .map_err(|source| PackageManagerError::Io { - context: format!( - "{promotion_error}; failed to restore {} from {}", - install_dir.display(), - quarantined_install_dir.display() - ), - source, - }) -} diff --git a/codex-rs/package-manager/src/package.rs b/codex-rs/package-manager/src/package.rs deleted file mode 100644 index 51be146084..0000000000 --- a/codex-rs/package-manager/src/package.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crate::PackageManagerError; -use crate::PackagePlatform; -use crate::PackageReleaseArchive; -use crate::archive::detect_single_package_root; -use serde::de::DeserializeOwned; -use std::path::Path; -use std::path::PathBuf; -use url::Url; - -/// Describes how a specific package is located, validated, and loaded. -/// -/// Implementations should treat this trait as the package manager contract: -/// -/// - [`Self::install_dir`] should resolve to a directory unique to the package version and -/// platform so concurrent versions never overwrite each other. -/// - [`Self::load_installed`] should fully validate whatever "installed" means for the package, -/// because cache resolution trusts a successful load as a valid install. -/// - The default [`Self::detect_extracted_root`] implementation expects the extracted archive to -/// contain a `manifest.json` at the package root or a single top-level directory that does. -pub trait ManagedPackage: Clone { - /// Error type surfaced by package-specific loading and validation. - type Error: From; - - /// The fully loaded package instance returned to callers. - type Installed: Clone; - - /// The decoded release manifest fetched from the remote source. - type ReleaseManifest: DeserializeOwned; - - /// Returns the default cache root relative to Codex home. - fn default_cache_root_relative(&self) -> &str; - - /// Returns the requested package version. - fn version(&self) -> &str; - - /// Returns the manifest URL for the requested version. - fn manifest_url(&self) -> Result; - - /// Returns the archive download URL for a platform-specific manifest entry. - fn archive_url(&self, archive: &PackageReleaseArchive) -> Result; - - /// Returns the version string stored in the fetched release manifest. - fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str; - - /// Selects the archive to download for the current platform. - fn platform_archive( - &self, - manifest: &Self::ReleaseManifest, - platform: PackagePlatform, - ) -> Result; - - /// Returns the final install directory for the package version and platform. - fn install_dir(&self, cache_root: &Path, platform: PackagePlatform) -> PathBuf; - - /// Returns the version string encoded in a fully loaded installed package. - fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str; - - /// Loads and validates an installed package from disk. - fn load_installed( - &self, - root_dir: PathBuf, - platform: PackagePlatform, - ) -> Result; - - /// Resolves the extracted package root before the staged install is promoted. - fn detect_extracted_root(&self, extraction_root: &Path) -> Result { - detect_single_package_root(extraction_root).map_err(Self::Error::from) - } -} diff --git a/codex-rs/package-manager/src/platform.rs b/codex-rs/package-manager/src/platform.rs deleted file mode 100644 index c179b803e3..0000000000 --- a/codex-rs/package-manager/src/platform.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::PackageManagerError; - -/// Supported OS and CPU combinations for managed packages. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PackagePlatform { - /// macOS on Apple Silicon. - DarwinArm64, - /// macOS on x86_64. - DarwinX64, - /// Linux on AArch64. - LinuxArm64, - /// Linux on x86_64. - LinuxX64, - /// Windows on AArch64. - WindowsArm64, - /// Windows on x86_64. - WindowsX64, -} - -impl PackagePlatform { - /// Detects the current process platform. - pub fn detect_current() -> Result { - match (std::env::consts::OS, std::env::consts::ARCH) { - ("macos", "aarch64") | ("macos", "arm64") => Ok(Self::DarwinArm64), - ("macos", "x86_64") => Ok(Self::DarwinX64), - ("linux", "aarch64") | ("linux", "arm64") => Ok(Self::LinuxArm64), - ("linux", "x86_64") => Ok(Self::LinuxX64), - ("windows", "aarch64") | ("windows", "arm64") => Ok(Self::WindowsArm64), - ("windows", "x86_64") => Ok(Self::WindowsX64), - (os, arch) => Err(PackageManagerError::UnsupportedPlatform { - os: os.to_string(), - arch: arch.to_string(), - }), - } - } - - /// Returns the manifest/cache string for this platform. - pub fn as_str(self) -> &'static str { - match self { - Self::DarwinArm64 => "darwin-arm64", - Self::DarwinX64 => "darwin-x64", - Self::LinuxArm64 => "linux-arm64", - Self::LinuxX64 => "linux-x64", - Self::WindowsArm64 => "windows-arm64", - Self::WindowsX64 => "windows-x64", - } - } -} diff --git a/codex-rs/package-manager/src/tests.rs b/codex-rs/package-manager/src/tests.rs deleted file mode 100644 index a80e98ae74..0000000000 --- a/codex-rs/package-manager/src/tests.rs +++ /dev/null @@ -1,700 +0,0 @@ -use crate::ArchiveFormat; -use crate::ManagedPackage; -use crate::PackageManager; -use crate::PackageManagerConfig; -use crate::PackageManagerError; -use crate::PackagePlatform; -use crate::PackageReleaseArchive; -use crate::archive::detect_single_package_root; -use crate::archive::extract_archive; -use crate::manager::promote_staged_install; -use crate::manager::quarantine_existing_install; -use pretty_assertions::assert_eq; -use serde::Deserialize; -use sha2::Digest; -use sha2::Sha256; -use std::collections::BTreeMap; -use std::fs::File; -use std::io::Cursor; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use tar::Builder; -use tar::EntryType; -use tempfile::TempDir; -use tokio::sync::Barrier; -use url::Url; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; -use zip::ZipWriter; -use zip::write::SimpleFileOptions; - -#[derive(Clone, Debug)] -struct TestPackage { - base_url: Url, - version: String, - fail_on_final_install_dir: bool, -} - -#[derive(Clone, Debug, Deserialize)] -struct TestReleaseManifest { - package_version: String, - platforms: BTreeMap, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct TestInstalledPackage { - version: String, - platform: PackagePlatform, - root_dir: PathBuf, -} - -impl ManagedPackage for TestPackage { - type Error = PackageManagerError; - type Installed = TestInstalledPackage; - type ReleaseManifest = TestReleaseManifest; - - fn default_cache_root_relative(&self) -> &str { - "packages/test-package" - } - - fn version(&self) -> &str { - &self.version - } - - fn manifest_url(&self) -> Result { - self.base_url - .join(&format!("test-package-v{}-manifest.json", self.version)) - .map_err(PackageManagerError::InvalidBaseUrl) - } - - fn archive_url(&self, archive: &PackageReleaseArchive) -> Result { - self.base_url - .join(&archive.archive) - .map_err(PackageManagerError::InvalidBaseUrl) - } - - fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str { - &manifest.package_version - } - - fn platform_archive( - &self, - manifest: &Self::ReleaseManifest, - platform: PackagePlatform, - ) -> Result { - manifest - .platforms - .get(platform.as_str()) - .cloned() - .ok_or_else(|| PackageManagerError::MissingPlatform(platform.as_str().to_string())) - } - - fn install_dir(&self, cache_root: &Path, platform: PackagePlatform) -> PathBuf { - cache_root.join(self.version()).join(platform.as_str()) - } - - fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str { - &package.version - } - - fn load_installed( - &self, - root_dir: PathBuf, - platform: PackagePlatform, - ) -> Result { - if self.fail_on_final_install_dir - && root_dir - .file_name() - .is_some_and(|name| name == platform.as_str()) - { - return Err(PackageManagerError::ArchiveExtraction(format!( - "refusing final install dir {}", - root_dir.display() - ))); - } - let manifest_path = root_dir.join("manifest.json"); - let version = - std::fs::read_to_string(&manifest_path).map_err(|source| PackageManagerError::Io { - context: format!("failed to read {}", manifest_path.display()), - source, - })?; - Ok(TestInstalledPackage { - version: version.trim().to_string(), - platform, - root_dir, - }) - } -} - -#[tokio::test] -async fn ensure_installed_downloads_and_extracts_zip_package() { - let server = MockServer::start().await; - let version = "0.1.0"; - let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str()); - let archive_bytes = build_zip_archive(version); - let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); - let manifest = serde_json::json!({ - "package_version": version, - "platforms": { - platform.as_str(): { - "archive": archive_name, - "sha256": archive_sha, - "format": "zip", - "size_bytes": archive_bytes.len(), - } - } - }); - Mock::given(method("GET")) - .and(path(format!("/test-package-v{version}-manifest.json"))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/{archive_name}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let package = TestPackage { - base_url: Url::parse(&format!("{}/", server.uri())) - .unwrap_or_else(|error| panic!("{error}")), - version: version.to_string(), - fail_on_final_install_dir: false, - }; - let manager = PackageManager::new(PackageManagerConfig::new( - codex_home.path().to_path_buf(), - package, - )); - - let installed = manager - .ensure_installed() - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!( - installed, - TestInstalledPackage { - version: version.to_string(), - platform, - root_dir: codex_home - .path() - .join("packages") - .join("test-package") - .join(version) - .join(platform.as_str()), - } - ); - - #[cfg(unix)] - { - let executable_mode = std::fs::metadata(installed.root_dir.join("bin/tool")) - .unwrap_or_else(|error| panic!("{error}")) - .permissions() - .mode(); - assert_eq!(executable_mode & 0o111, 0o111); - } -} - -#[tokio::test] -async fn resolve_cached_uses_custom_cache_root() { - let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let cache_root = codex_home.path().join("custom-cache"); - let install_dir = cache_root.join("0.1.0").join(platform.as_str()); - std::fs::create_dir_all(&install_dir).unwrap_or_else(|error| panic!("{error}")); - std::fs::write(install_dir.join("manifest.json"), "0.1.0") - .unwrap_or_else(|error| panic!("{error}")); - - let manager = PackageManager::new( - PackageManagerConfig::new( - codex_home.path().to_path_buf(), - TestPackage { - base_url: Url::parse("https://example.test/") - .unwrap_or_else(|error| panic!("{error}")), - version: "0.1.0".to_string(), - fail_on_final_install_dir: false, - }, - ) - .with_cache_root(cache_root.clone()), - ); - - let installed = manager - .resolve_cached() - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!( - installed, - Some(TestInstalledPackage { - version: "0.1.0".to_string(), - platform, - root_dir: cache_root.join("0.1.0").join(platform.as_str()), - }) - ); -} - -#[tokio::test] -async fn ensure_installed_replaces_invalid_cached_install() { - let server = MockServer::start().await; - let version = "0.1.0"; - let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str()); - let archive_bytes = build_zip_archive(version); - let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); - let manifest = serde_json::json!({ - "package_version": version, - "platforms": { - platform.as_str(): { - "archive": archive_name, - "sha256": archive_sha, - "format": "zip", - "size_bytes": archive_bytes.len(), - } - } - }); - Mock::given(method("GET")) - .and(path(format!("/test-package-v{version}-manifest.json"))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/{archive_name}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let install_dir = codex_home - .path() - .join("packages") - .join("test-package") - .join(version) - .join(platform.as_str()); - std::fs::create_dir_all(&install_dir).unwrap_or_else(|error| panic!("{error}")); - std::fs::write(install_dir.join("broken.txt"), "stale") - .unwrap_or_else(|error| panic!("{error}")); - - let manager = PackageManager::new(PackageManagerConfig::new( - codex_home.path().to_path_buf(), - TestPackage { - base_url: Url::parse(&format!("{}/", server.uri())) - .unwrap_or_else(|error| panic!("{error}")), - version: version.to_string(), - fail_on_final_install_dir: false, - }, - )); - - let installed = manager - .ensure_installed() - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!(installed.version, version); - assert!(installed.root_dir.join("manifest.json").exists()); - assert!(!installed.root_dir.join("broken.txt").exists()); -} - -#[tokio::test] -async fn ensure_installed_rejects_manifest_version_mismatch() { - let server = MockServer::start().await; - let version = "0.1.0"; - let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str()); - let manifest = serde_json::json!({ - "package_version": "0.2.0", - "platforms": { - platform.as_str(): { - "archive": archive_name, - "sha256": "deadbeef", - "format": "zip", - "size_bytes": 1, - } - } - }); - Mock::given(method("GET")) - .and(path(format!("/test-package-v{version}-manifest.json"))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let manager = PackageManager::new(PackageManagerConfig::new( - codex_home.path().to_path_buf(), - TestPackage { - base_url: Url::parse(&format!("{}/", server.uri())) - .unwrap_or_else(|error| panic!("{error}")), - version: version.to_string(), - fail_on_final_install_dir: false, - }, - )); - - let error = manager - .ensure_installed() - .await - .expect_err("manifest version mismatch should fail"); - assert!(matches!( - error, - PackageManagerError::UnexpectedPackageVersion { expected, actual } - if expected == "0.1.0" && actual == "0.2.0" - )); -} - -#[tokio::test] -async fn ensure_installed_serializes_concurrent_installs() { - let server = MockServer::start().await; - let version = "0.1.0"; - let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str()); - let archive_bytes = build_zip_archive(version); - let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); - let manifest = serde_json::json!({ - "package_version": version, - "platforms": { - platform.as_str(): { - "archive": archive_name, - "sha256": archive_sha, - "format": "zip", - "size_bytes": archive_bytes.len(), - } - } - }); - Mock::given(method("GET")) - .and(path(format!("/test-package-v{version}-manifest.json"))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/{archive_name}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) - .expect(1) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let config = PackageManagerConfig::new( - codex_home.path().to_path_buf(), - TestPackage { - base_url: Url::parse(&format!("{}/", server.uri())) - .unwrap_or_else(|error| panic!("{error}")), - version: version.to_string(), - fail_on_final_install_dir: false, - }, - ); - let manager_one = PackageManager::new(config.clone()); - let manager_two = PackageManager::new(config); - let barrier = Arc::new(Barrier::new(2)); - let barrier_one = Arc::clone(&barrier); - let barrier_two = Arc::clone(&barrier); - - let (first, second) = tokio::join!( - async { - barrier_one.wait().await; - manager_one.ensure_installed().await - }, - async { - barrier_two.wait().await; - manager_two.ensure_installed().await - } - ); - - let first = first.unwrap_or_else(|error| panic!("{error}")); - let second = second.unwrap_or_else(|error| panic!("{error}")); - assert_eq!(first, second); -} - -#[tokio::test] -async fn ensure_installed_rejects_unexpected_archive_size() { - let server = MockServer::start().await; - let version = "0.1.0"; - let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str()); - let archive_bytes = build_zip_archive(version); - let actual_size = archive_bytes.len() as u64; - let expected_size = (archive_bytes.len() + 1) as u64; - let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); - let manifest = serde_json::json!({ - "package_version": version, - "platforms": { - platform.as_str(): { - "archive": archive_name, - "sha256": archive_sha, - "format": "zip", - "size_bytes": expected_size, - } - } - }); - Mock::given(method("GET")) - .and(path(format!("/test-package-v{version}-manifest.json"))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/{archive_name}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let manager = PackageManager::new(PackageManagerConfig::new( - codex_home.path().to_path_buf(), - TestPackage { - base_url: Url::parse(&format!("{}/", server.uri())) - .unwrap_or_else(|error| panic!("{error}")), - version: version.to_string(), - fail_on_final_install_dir: false, - }, - )); - - let error = manager - .ensure_installed() - .await - .expect_err("archive size mismatch should fail"); - assert!(matches!( - error, - PackageManagerError::UnexpectedArchiveSize { expected, actual } - if expected == expected_size && actual == actual_size - )); -} - -#[tokio::test] -async fn staged_install_restore_keeps_previous_install_on_failed_promotion() { - let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let install_dir = temp.path().join("install"); - let staged_dir = temp.path().join("missing-staged"); - std::fs::create_dir_all(&install_dir).unwrap_or_else(|error| panic!("{error}")); - std::fs::write(install_dir.join("manifest.json"), "0.1.0") - .unwrap_or_else(|error| panic!("{error}")); - - let quarantined = quarantine_existing_install(&install_dir) - .await - .unwrap_or_else(|error| panic!("{error}")); - let promotion_error = promote_staged_install(&staged_dir, &install_dir) - .await - .expect_err("promotion should fail"); - crate::manager::restore_quarantined_install( - &install_dir, - quarantined.as_deref(), - &promotion_error, - ) - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert!(install_dir.join("manifest.json").exists()); - assert_eq!( - std::fs::read_to_string(install_dir.join("manifest.json")) - .unwrap_or_else(|error| panic!("{error}")), - "0.1.0" - ); -} - -#[tokio::test] -async fn ensure_installed_restores_previous_install_when_final_validation_fails() { - let server = MockServer::start().await; - let version = "0.1.0"; - let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str()); - let archive_bytes = build_zip_archive(version); - let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); - let manifest = serde_json::json!({ - "package_version": version, - "platforms": { - platform.as_str(): { - "archive": archive_name, - "sha256": archive_sha, - "format": "zip", - "size_bytes": archive_bytes.len(), - } - } - }); - Mock::given(method("GET")) - .and(path(format!("/test-package-v{version}-manifest.json"))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!("/{archive_name}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let install_dir = codex_home - .path() - .join("packages") - .join("test-package") - .join(version) - .join(platform.as_str()); - std::fs::create_dir_all(&install_dir).unwrap_or_else(|error| panic!("{error}")); - std::fs::write(install_dir.join("manifest.json"), "0.0.9") - .unwrap_or_else(|error| panic!("{error}")); - - let error = PackageManager::new(PackageManagerConfig::new( - codex_home.path().to_path_buf(), - TestPackage { - base_url: Url::parse(&format!("{}/", server.uri())) - .unwrap_or_else(|error| panic!("{error}")), - version: version.to_string(), - fail_on_final_install_dir: true, - }, - )) - .ensure_installed() - .await - .expect_err("final validation should fail"); - - assert!( - matches!(error, PackageManagerError::ArchiveExtraction(message) if message.contains("refusing final install dir")) - ); - assert_eq!( - std::fs::read_to_string(install_dir.join("manifest.json")) - .unwrap_or_else(|error| panic!("{error}")), - "0.0.9" - ); - assert!( - !install_dir - .parent() - .unwrap_or_else(|| panic!("install dir should have a parent")) - .read_dir() - .unwrap_or_else(|error| panic!("{error}")) - .any(|entry| { - entry - .unwrap_or_else(|error| panic!("{error}")) - .file_name() - .to_string_lossy() - .contains(".replaced-") - }) - ); -} - -#[test] -fn tar_gz_extraction_supports_default_package_root_detection() { - let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let archive_path = temp.path().join("package.tar.gz"); - let extraction_root = temp.path().join("extract"); - std::fs::create_dir_all(&extraction_root).unwrap_or_else(|error| panic!("{error}")); - write_tar_gz_archive(&archive_path, "0.2.0"); - - extract_archive(&archive_path, &extraction_root, ArchiveFormat::TarGz) - .unwrap_or_else(|error| panic!("{error}")); - let package_root = - detect_single_package_root(&extraction_root).unwrap_or_else(|error| panic!("{error}")); - - assert!(package_root.join("manifest.json").exists()); -} - -#[test] -fn tar_gz_extraction_rejects_symlinks() { - let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let archive_path = temp.path().join("package.tar.gz"); - let extraction_root = temp.path().join("extract"); - std::fs::create_dir_all(&extraction_root).unwrap_or_else(|error| panic!("{error}")); - write_tar_gz_archive_with_symlink(&archive_path); - - let error = extract_archive(&archive_path, &extraction_root, ArchiveFormat::TarGz) - .expect_err("symlink entry should fail"); - assert!( - matches!(error, PackageManagerError::ArchiveExtraction(message) if message.contains("unsupported type")) - ); -} - -#[test] -fn zip_extraction_rejects_parent_paths() { - let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let archive_path = temp.path().join("package.zip"); - let extraction_root = temp.path().join("extract"); - std::fs::create_dir_all(&extraction_root).unwrap_or_else(|error| panic!("{error}")); - write_zip_archive_with_parent_path(&archive_path); - - let error = extract_archive(&archive_path, &extraction_root, ArchiveFormat::Zip) - .expect_err("parent path entry should fail"); - assert!( - matches!(error, PackageManagerError::ArchiveExtraction(message) if message.contains("escapes extraction root")) - ); -} - -fn build_zip_archive(version: &str) -> Vec { - let mut bytes = Cursor::new(Vec::new()); - { - let mut zip = ZipWriter::new(&mut bytes); - let options = SimpleFileOptions::default(); - zip.start_file("test-package/manifest.json", options) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(version.as_bytes()) - .unwrap_or_else(|error| panic!("{error}")); - zip.start_file("test-package/bin/tool", options.unix_permissions(0o755)) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(b"#!/bin/sh\n") - .unwrap_or_else(|error| panic!("{error}")); - zip.finish().unwrap_or_else(|error| panic!("{error}")); - } - bytes.into_inner() -} - -fn write_zip_archive_with_parent_path(archive_path: &Path) { - let file = File::create(archive_path).unwrap_or_else(|error| panic!("{error}")); - let mut zip = ZipWriter::new(file); - let options = SimpleFileOptions::default(); - zip.start_file("../escape.txt", options) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(b"escape") - .unwrap_or_else(|error| panic!("{error}")); - zip.finish().unwrap_or_else(|error| panic!("{error}")); -} - -fn write_tar_gz_archive(archive_path: &Path, version: &str) { - let file = File::create(archive_path).unwrap_or_else(|error| panic!("{error}")); - let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); - let mut builder = Builder::new(encoder); - - append_tar_file( - &mut builder, - "test-package/manifest.json", - version.as_bytes(), - ); - builder.finish().unwrap_or_else(|error| panic!("{error}")); -} - -fn write_tar_gz_archive_with_symlink(archive_path: &Path) { - let file = File::create(archive_path).unwrap_or_else(|error| panic!("{error}")); - let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); - let mut builder = Builder::new(encoder); - - append_tar_file(&mut builder, "test-package/manifest.json", b"0.2.0"); - - let mut header = tar::Header::new_gnu(); - header.set_entry_type(EntryType::Symlink); - header.set_size(0); - header.set_mode(0o777); - header - .set_link_name("/tmp/escape") - .unwrap_or_else(|error| panic!("{error}")); - header.set_cksum(); - builder - .append_data(&mut header, "test-package/link", std::io::empty()) - .unwrap_or_else(|error| panic!("{error}")); - - builder.finish().unwrap_or_else(|error| panic!("{error}")); -} - -fn append_tar_file( - builder: &mut Builder>, - path: &str, - contents: &[u8], -) { - let mut header = tar::Header::new_gnu(); - header.set_size(contents.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - builder - .append_data(&mut header, path, contents) - .unwrap_or_else(|error| panic!("{error}")); -} From 2ef91b71406da6c88cad959d647915b4e7909f1f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 27 Mar 2026 02:31:08 -0700 Subject: [PATCH 012/185] chore: move pty and windows sandbox to Rust 2024 (#15954) ## Why `codex-utils-pty` and `codex-windows-sandbox` were the remaining crates in `codex-rs` that still overrode the workspace's Rust 2024 edition. Moving them forward in a separate PR keeps the baseline edition update isolated from the follow-on Bazel clippy workflow in #15955, while making linting and formatting behavior consistent with the rest of the workspace. This PR also needs Cargo and Bazel to agree on the edition for `codex-windows-sandbox`. Without the Bazel-side sync, the experimental Bazel app-server builds fail once they compile `windows-sandbox-rs`. ## What changed - switch `codex-rs/utils/pty` and `codex-rs/windows-sandbox-rs` to `edition = "2024"` - update `codex-utils-pty` callsites and tests to use the collapsed `if let` form that Clippy expects under the new edition - fix the Rust 2024 fallout in `windows-sandbox-rs`, including the reserved `gen` identifier, `unsafe extern` requirements, and new Clippy findings that surfaced under the edition bump - keep the edition bump separate from a larger unsafe cleanup by temporarily allowing `unsafe_op_in_unsafe_fn` in the Windows entrypoint modules that now report it under Rust 2024 - update `codex-rs/windows-sandbox-rs/BUILD.bazel` to `crate_edition = "2024"` so Bazel compiles the crate with the same edition as Cargo --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/15954). * #15976 * #15955 * __->__ #15954 --- codex-rs/utils/pty/Cargo.toml | 2 +- codex-rs/utils/pty/src/lib.rs | 4 +- codex-rs/utils/pty/src/pipe.rs | 8 +- codex-rs/utils/pty/src/process.rs | 42 ++++---- codex-rs/utils/pty/src/pty.rs | 4 +- codex-rs/utils/pty/src/tests.rs | 12 +-- codex-rs/utils/pty/src/win/conpty.rs | 2 +- codex-rs/utils/pty/src/win/mod.rs | 6 +- codex-rs/utils/pty/src/win/procthreadattr.rs | 2 +- codex-rs/utils/pty/src/win/psuedocon.rs | 4 +- codex-rs/windows-sandbox-rs/BUILD.bazel | 1 - codex-rs/windows-sandbox-rs/Cargo.toml | 2 +- codex-rs/windows-sandbox-rs/src/audit.rs | 8 +- codex-rs/windows-sandbox-rs/src/conpty/mod.rs | 2 +- .../src/conpty/proc_thread_attr.rs | 2 +- codex-rs/windows-sandbox-rs/src/desktop.rs | 2 +- .../src/elevated/command_runner_win.rs | 59 +++++------ .../src/elevated/ipc_framed.rs | 2 +- .../windows-sandbox-rs/src/elevated_impl.rs | 42 ++++---- codex-rs/windows-sandbox-rs/src/env.rs | 16 +-- codex-rs/windows-sandbox-rs/src/firewall.rs | 15 ++- .../src/helper_materialization.rs | 13 ++- codex-rs/windows-sandbox-rs/src/lib.rs | 97 ++++++++++--------- codex-rs/windows-sandbox-rs/src/logging.rs | 11 +-- .../windows-sandbox-rs/src/read_acl_mutex.rs | 4 +- .../windows-sandbox-rs/src/sandbox_users.rs | 18 ++-- .../windows-sandbox-rs/src/setup_error.rs | 17 ++-- .../windows-sandbox-rs/src/setup_main_win.rs | 22 ++--- .../src/setup_orchestrator.rs | 80 ++++++++------- codex-rs/windows-sandbox-rs/src/token.rs | 4 +- 30 files changed, 255 insertions(+), 248 deletions(-) diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml index ec221bfc94..cf98ac4904 100644 --- a/codex-rs/utils/pty/Cargo.toml +++ b/codex-rs/utils/pty/Cargo.toml @@ -1,5 +1,5 @@ [package] -edition = "2021" +edition = "2024" license.workspace = true name = "codex-utils-pty" version.workspace = true diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 48fcd53b50..87898ddf71 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -13,14 +13,14 @@ pub const DEFAULT_OUTPUT_BYTES_CAP: usize = 1024 * 1024; pub use pipe::spawn_process as spawn_pipe_process; /// Spawn a non-interactive process using regular pipes, but close stdin immediately. pub use pipe::spawn_process_no_stdin as spawn_pipe_process_no_stdin; -/// Combine stdout/stderr receivers into a single broadcast receiver. -pub use process::combine_output_receivers; /// Handle for interacting with a spawned process (PTY or pipe). pub use process::ProcessHandle; /// Bundle of process handles plus split output and exit receivers returned by spawn helpers. pub use process::SpawnedProcess; /// Terminal size in character cells used for PTY spawn and resize operations. pub use process::TerminalSize; +/// Combine stdout/stderr receivers into a single broadcast receiver. +pub use process::combine_output_receivers; /// Backwards-compatible alias for ProcessHandle. pub type ExecCommandSession = ProcessHandle; /// Backwards-compatible alias for SpawnedProcess. diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index 2cebdef49f..903d1cf55e 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -3,9 +3,9 @@ use std::io; use std::io::ErrorKind; use std::path::Path; use std::process::Stdio; -use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicBool; use anyhow::Result; use tokio::io::AsyncRead; @@ -64,11 +64,7 @@ fn kill_process(pid: u32) -> io::Result<()> { let success = winapi::um::processthreadsapi::TerminateProcess(handle, 1); let err = io::Error::last_os_error(); winapi::um::handleapi::CloseHandle(handle); - if success == 0 { - Err(err) - } else { - Ok(()) - } + if success == 0 { Err(err) } else { Ok(()) } } } diff --git a/codex-rs/utils/pty/src/process.rs b/codex-rs/utils/pty/src/process.rs index 61bdd3cd1b..8459f2b68f 100644 --- a/codex-rs/utils/pty/src/process.rs +++ b/codex-rs/utils/pty/src/process.rs @@ -2,9 +2,9 @@ use core::fmt; use std::io; #[cfg(unix)] use std::os::fd::RawFd; -use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicBool; use anyhow::anyhow; use portable_pty::MasterPty; @@ -118,10 +118,10 @@ impl ProcessHandle { /// Returns a channel sender for writing raw bytes to the child stdin. pub fn writer_sender(&self) -> mpsc::Sender> { - if let Ok(writer_tx) = self.writer_tx.lock() { - if let Some(writer_tx) = writer_tx.as_ref() { - return writer_tx.clone(); - } + if let Ok(writer_tx) = self.writer_tx.lock() + && let Some(writer_tx) = writer_tx.as_ref() + { + return writer_tx.clone(); } let (writer_tx, writer_rx) = mpsc::channel(1); @@ -165,10 +165,10 @@ impl ProcessHandle { /// Attempts to kill the child while leaving the reader/writer tasks alive /// so callers can still drain output until EOF. pub fn request_terminate(&self) { - if let Ok(mut killer_opt) = self.killer.lock() { - if let Some(mut killer) = killer_opt.take() { - let _ = killer.kill(); - } + if let Ok(mut killer_opt) = self.killer.lock() + && let Some(mut killer) = killer_opt.take() + { + let _ = killer.kill(); } } @@ -176,25 +176,25 @@ impl ProcessHandle { pub fn terminate(&self) { self.request_terminate(); - if let Ok(mut h) = self.reader_handle.lock() { - if let Some(handle) = h.take() { - handle.abort(); - } + if let Ok(mut h) = self.reader_handle.lock() + && let Some(handle) = h.take() + { + handle.abort(); } if let Ok(mut handles) = self.reader_abort_handles.lock() { for handle in handles.drain(..) { handle.abort(); } } - if let Ok(mut h) = self.writer_handle.lock() { - if let Some(handle) = h.take() { - handle.abort(); - } + if let Ok(mut h) = self.writer_handle.lock() + && let Some(handle) = h.take() + { + handle.abort(); } - if let Ok(mut h) = self.wait_handle.lock() { - if let Some(handle) = h.take() { - handle.abort(); - } + if let Ok(mut h) = self.wait_handle.lock() + && let Some(handle) = h.take() + { + handle.abort(); } } } diff --git a/codex-rs/utils/pty/src/pty.rs b/codex-rs/utils/pty/src/pty.rs index e7bc9f9c97..4ae6facb7d 100644 --- a/codex-rs/utils/pty/src/pty.rs +++ b/codex-rs/utils/pty/src/pty.rs @@ -15,15 +15,15 @@ use std::path::Path; use std::process::Command as StdCommand; #[cfg(unix)] use std::process::Stdio; -use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicBool; use std::time::Duration; use anyhow::Result; +use portable_pty::CommandBuilder; #[cfg(not(windows))] use portable_pty::native_pty_system; -use portable_pty::CommandBuilder; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::task::JoinHandle; diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index e7135114d9..fa0e821a24 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -3,6 +3,8 @@ use std::path::Path; use pretty_assertions::assert_eq; +use crate::SpawnedProcess; +use crate::TerminalSize; use crate::combine_output_receivers; #[cfg(unix)] use crate::pipe::spawn_process_no_stdin_with_inherited_fds; @@ -11,18 +13,15 @@ use crate::pty::spawn_process_with_inherited_fds; use crate::spawn_pipe_process; use crate::spawn_pipe_process_no_stdin; use crate::spawn_pty_process; -use crate::SpawnedProcess; -use crate::TerminalSize; fn find_python() -> Option { for candidate in ["python3", "python"] { if let Ok(output) = std::process::Command::new(candidate) .arg("--version") .output() + && output.status.success() { - if output.status.success() { - return Some(candidate.to_string()); - } + return Some(candidate.to_string()); } } None @@ -855,8 +854,7 @@ async fn pty_spawn_with_inherited_fds_supports_resize() -> anyhow::Result<()> { let write_end = unsafe { std::fs::File::from_raw_fd(fds[1]) }; let env_map: HashMap = std::env::vars().collect(); - let script = - "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\""; + let script = "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\""; let spawned = spawn_process_with_inherited_fds( "/bin/sh", &["-c".to_string(), script.to_string()], diff --git a/codex-rs/utils/pty/src/win/conpty.rs b/codex-rs/utils/pty/src/win/conpty.rs index a0e5903810..ae490160ae 100644 --- a/codex-rs/utils/pty/src/win/conpty.rs +++ b/codex-rs/utils/pty/src/win/conpty.rs @@ -22,13 +22,13 @@ use crate::win::psuedocon::PsuedoCon; use anyhow::Error; use filedescriptor::FileDescriptor; use filedescriptor::Pipe; -use portable_pty::cmdbuilder::CommandBuilder; use portable_pty::Child; use portable_pty::MasterPty; use portable_pty::PtyPair; use portable_pty::PtySize; use portable_pty::PtySystem; use portable_pty::SlavePty; +use portable_pty::cmdbuilder::CommandBuilder; use std::mem::ManuallyDrop; use std::os::windows::io::AsRawHandle; use std::os::windows::io::RawHandle; diff --git a/codex-rs/utils/pty/src/win/mod.rs b/codex-rs/utils/pty/src/win/mod.rs index bcd65eb487..78f5af2ce7 100644 --- a/codex-rs/utils/pty/src/win/mod.rs +++ b/codex-rs/utils/pty/src/win/mod.rs @@ -142,11 +142,7 @@ impl Child for WinChild { fn process_id(&self) -> Option { let res = unsafe { GetProcessId(self.proc.lock().unwrap().as_raw_handle() as _) }; - if res == 0 { - None - } else { - Some(res) - } + if res == 0 { None } else { Some(res) } } fn as_raw_handle(&self) -> Option { diff --git a/codex-rs/utils/pty/src/win/procthreadattr.rs b/codex-rs/utils/pty/src/win/procthreadattr.rs index 6d464e99de..c7cf68fed9 100644 --- a/codex-rs/utils/pty/src/win/procthreadattr.rs +++ b/codex-rs/utils/pty/src/win/procthreadattr.rs @@ -19,8 +19,8 @@ // SOFTWARE. use super::psuedocon::HPCON; -use anyhow::ensure; use anyhow::Error; +use anyhow::ensure; use std::io::Error as IoError; use std::mem; use std::ptr; diff --git a/codex-rs/utils/pty/src/win/psuedocon.rs b/codex-rs/utils/pty/src/win/psuedocon.rs index b1c72a739d..3476665633 100644 --- a/codex-rs/utils/pty/src/win/psuedocon.rs +++ b/codex-rs/utils/pty/src/win/psuedocon.rs @@ -21,9 +21,9 @@ use super::WinChild; use crate::win::procthreadattr::ProcThreadAttributeList; +use anyhow::Error; use anyhow::bail; use anyhow::ensure; -use anyhow::Error; use filedescriptor::FileDescriptor; use filedescriptor::OwnedHandle; use lazy_static::lazy_static; @@ -356,8 +356,8 @@ fn append_quoted(arg: &OsStr, cmdline: &mut Vec) { #[cfg(test)] mod tests { - use super::windows_build_number; use super::MIN_CONPTY_BUILD; + use super::windows_build_number; #[test] fn windows_build_number_returns_value() { diff --git a/codex-rs/windows-sandbox-rs/BUILD.bazel b/codex-rs/windows-sandbox-rs/BUILD.bazel index b09ee38ed0..e3f0112a85 100644 --- a/codex-rs/windows-sandbox-rs/BUILD.bazel +++ b/codex-rs/windows-sandbox-rs/BUILD.bazel @@ -7,5 +7,4 @@ codex_rust_crate( "Cargo.toml", "codex-windows-sandbox-setup.manifest", ], - crate_edition = "2021", ) diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 91cb178435..0fb2192578 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] build = "build.rs" -edition = "2021" +edition = "2024" license.workspace = true name = "codex-windows-sandbox" version.workspace = true diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index d85c5dea8e..0f115323ea 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -33,10 +33,10 @@ const SKIP_DIR_SUFFIXES: &[&str] = &[ ]; fn unique_push(set: &mut HashSet, out: &mut Vec, p: PathBuf) { - if let Ok(abs) = p.canonicalize() { - if set.insert(abs.clone()) { - out.push(abs); - } + if let Ok(abs) = p.canonicalize() + && set.insert(abs.clone()) + { + out.push(abs); } } diff --git a/codex-rs/windows-sandbox-rs/src/conpty/mod.rs b/codex-rs/windows-sandbox-rs/src/conpty/mod.rs index 9c05e9ea67..c7b992f518 100644 --- a/codex-rs/windows-sandbox-rs/src/conpty/mod.rs +++ b/codex-rs/windows-sandbox-rs/src/conpty/mod.rs @@ -23,8 +23,8 @@ use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::System::Console::ClosePseudoConsole; -use windows_sys::Win32::System::Threading::CreateProcessAsUserW; use windows_sys::Win32::System::Threading::CREATE_UNICODE_ENVIRONMENT; +use windows_sys::Win32::System::Threading::CreateProcessAsUserW; use windows_sys::Win32::System::Threading::EXTENDED_STARTUPINFO_PRESENT; use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; use windows_sys::Win32::System::Threading::STARTF_USESTDHANDLES; diff --git a/codex-rs/windows-sandbox-rs/src/conpty/proc_thread_attr.rs b/codex-rs/windows-sandbox-rs/src/conpty/proc_thread_attr.rs index 3fe3870f9c..393172a0f8 100644 --- a/codex-rs/windows-sandbox-rs/src/conpty/proc_thread_attr.rs +++ b/codex-rs/windows-sandbox-rs/src/conpty/proc_thread_attr.rs @@ -8,8 +8,8 @@ use std::io; use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::System::Threading::DeleteProcThreadAttributeList; use windows_sys::Win32::System::Threading::InitializeProcThreadAttributeList; -use windows_sys::Win32::System::Threading::UpdateProcThreadAttribute; use windows_sys::Win32::System::Threading::LPPROC_THREAD_ATTRIBUTE_LIST; +use windows_sys::Win32::System::Threading::UpdateProcThreadAttribute; const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016; diff --git a/codex-rs/windows-sandbox-rs/src/desktop.rs b/codex-rs/windows-sandbox-rs/src/desktop.rs index 822e43b071..d2aa129b92 100644 --- a/codex-rs/windows-sandbox-rs/src/desktop.rs +++ b/codex-rs/windows-sandbox-rs/src/desktop.rs @@ -89,7 +89,7 @@ struct PrivateDesktop { impl PrivateDesktop { fn create(logs_base_dir: Option<&Path>) -> Result { let mut rng = SmallRng::from_entropy(); - let name = format!("CodexSandboxDesktop-{:x}", rng.gen::()); + let name = format!("CodexSandboxDesktop-{:x}", rng.r#gen::()); let name_wide = to_wide(&name); let handle = unsafe { CreateDesktopW( diff --git a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs index 82347fcca7..5572c1bf8e 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs @@ -8,34 +8,35 @@ //! path spawns the child directly and does not use this runner. #![cfg(target_os = "windows")] +#![allow(unsafe_op_in_unsafe_fn)] use anyhow::Context; use anyhow::Result; +use codex_windows_sandbox::PipeSpawnHandles; +use codex_windows_sandbox::SandboxPolicy; +use codex_windows_sandbox::StderrMode; +use codex_windows_sandbox::StdinMode; use codex_windows_sandbox::allow_null_device; use codex_windows_sandbox::convert_string_sid_to_sid; use codex_windows_sandbox::create_readonly_token_with_caps_from; use codex_windows_sandbox::create_workspace_write_token_with_caps_from; use codex_windows_sandbox::get_current_token_for_restriction; use codex_windows_sandbox::hide_current_user_profile_dir; -use codex_windows_sandbox::ipc_framed::decode_bytes; -use codex_windows_sandbox::ipc_framed::encode_bytes; -use codex_windows_sandbox::ipc_framed::read_frame; -use codex_windows_sandbox::ipc_framed::write_frame; use codex_windows_sandbox::ipc_framed::ErrorPayload; use codex_windows_sandbox::ipc_framed::ExitPayload; use codex_windows_sandbox::ipc_framed::FramedMessage; use codex_windows_sandbox::ipc_framed::Message; use codex_windows_sandbox::ipc_framed::OutputPayload; use codex_windows_sandbox::ipc_framed::OutputStream; +use codex_windows_sandbox::ipc_framed::decode_bytes; +use codex_windows_sandbox::ipc_framed::encode_bytes; +use codex_windows_sandbox::ipc_framed::read_frame; +use codex_windows_sandbox::ipc_framed::write_frame; use codex_windows_sandbox::log_note; use codex_windows_sandbox::parse_policy; use codex_windows_sandbox::read_handle_loop; use codex_windows_sandbox::spawn_process_with_pipes; use codex_windows_sandbox::to_wide; -use codex_windows_sandbox::PipeSpawnHandles; -use codex_windows_sandbox::SandboxPolicy; -use codex_windows_sandbox::StderrMode; -use codex_windows_sandbox::StdinMode; use std::ffi::c_void; use std::fs::File; use std::os::windows::io::FromRawHandle; @@ -46,9 +47,9 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; -use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Storage::FileSystem::CreateFileW; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; @@ -56,16 +57,16 @@ use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; use windows_sys::Win32::System::Console::ClosePseudoConsole; use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; use windows_sys::Win32::System::JobObjects::CreateJobObjectW; +use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; +use windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION; use windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation; use windows_sys::Win32::System::JobObjects::SetInformationJobObject; -use windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION; -use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; use windows_sys::Win32::System::Threading::GetExitCodeProcess; use windows_sys::Win32::System::Threading::GetProcessId; -use windows_sys::Win32::System::Threading::TerminateProcess; -use windows_sys::Win32::System::Threading::WaitForSingleObject; use windows_sys::Win32::System::Threading::INFINITE; use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; +use windows_sys::Win32::System::Threading::TerminateProcess; +use windows_sys::Win32::System::Threading::WaitForSingleObject; #[path = "cwd_junction.rs"] mod cwd_junction; @@ -332,13 +333,13 @@ fn spawn_output_reader( }, }, }; - if let Ok(mut guard) = writer.lock() { - if let Err(err) = write_frame(&mut *guard, &msg) { - log_note( - &format!("runner output write failed: {err}"), - log_dir.as_deref(), - ); - } + if let Ok(mut guard) = writer.lock() + && let Err(err) = write_frame(&mut *guard, &msg) + { + log_note( + &format!("runner output write failed: {err}"), + log_dir.as_deref(), + ); } }) } @@ -382,11 +383,11 @@ fn spawn_input_loop( } } Message::Terminate { .. } => { - if let Ok(guard) = process_handle.lock() { - if let Some(handle) = guard.as_ref() { - unsafe { - let _ = TerminateProcess(*handle, 1); - } + if let Ok(guard) = process_handle.lock() + && let Some(handle) = guard.as_ref() + { + unsafe { + let _ = TerminateProcess(*handle, 1); } } } @@ -544,10 +545,10 @@ pub fn main() -> Result<()> { }, }, }; - if let Ok(mut guard) = pipe_write.lock() { - if let Err(err) = write_frame(&mut *guard, &exit_msg) { - log_note(&format!("runner exit write failed: {err}"), log_dir); - } + if let Ok(mut guard) = pipe_write.lock() + && let Err(err) = write_frame(&mut *guard, &exit_msg) + { + log_note(&format!("runner exit write failed: {err}"), log_dir); } std::process::exit(exit_code); diff --git a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs index 37590c34b9..d56ec1d0e0 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs @@ -8,8 +8,8 @@ //! through the elevated runner. use anyhow::Result; -use base64::engine::general_purpose::STANDARD; use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 365ac37d6f..cc2b7fdad6 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -16,37 +16,37 @@ pub struct ElevatedSandboxCaptureRequest<'a> { mod windows_impl { use super::ElevatedSandboxCaptureRequest; use crate::acl::allow_null_device; - use crate::allow::compute_allow_paths; use crate::allow::AllowDenyPaths; + use crate::allow::compute_allow_paths; use crate::cap::load_or_create_cap_sids; use crate::env::ensure_non_interactive_pager; use crate::env::inherit_path_env; use crate::env::normalize_null_device_env; - use crate::helper_materialization::resolve_helper_for_launch; use crate::helper_materialization::HelperExecutable; + use crate::helper_materialization::resolve_helper_for_launch; use crate::identity::require_logon_sandbox_creds; - use crate::ipc_framed::decode_bytes; - use crate::ipc_framed::read_frame; - use crate::ipc_framed::write_frame; use crate::ipc_framed::FramedMessage; use crate::ipc_framed::Message; use crate::ipc_framed::OutputStream; use crate::ipc_framed::SpawnRequest; + use crate::ipc_framed::decode_bytes; + use crate::ipc_framed::read_frame; + use crate::ipc_framed::write_frame; use crate::logging::log_failure; use crate::logging::log_note; use crate::logging::log_start; use crate::logging::log_success; - use crate::policy::parse_policy; use crate::policy::SandboxPolicy; + use crate::policy::parse_policy; use crate::token::convert_string_sid_to_sid; use crate::winutil::quote_windows_arg; use crate::winutil::resolve_sid; use crate::winutil::string_from_sid_bytes; use crate::winutil::to_wide; use anyhow::Result; - use rand::rngs::SmallRng; use rand::Rng; use rand::SeedableRng; + use rand::rngs::SmallRng; use std::collections::HashMap; use std::ffi::c_void; use std::fs::File; @@ -84,16 +84,16 @@ mod windows_impl { return Some(cur); } if marker.is_file() { - if let Ok(txt) = std::fs::read_to_string(&marker) { - if let Some(rest) = txt.trim().strip_prefix("gitdir:") { - let gitdir = rest.trim(); - let resolved = if Path::new(gitdir).is_absolute() { - PathBuf::from(gitdir) - } else { - cur.join(gitdir) - }; - return resolved.parent().map(|p| p.to_path_buf()).or(Some(cur)); - } + if let Ok(txt) = std::fs::read_to_string(&marker) + && let Some(rest) = txt.trim().strip_prefix("gitdir:") + { + let gitdir = rest.trim(); + let resolved = if Path::new(gitdir).is_absolute() { + PathBuf::from(gitdir) + } else { + cur.join(gitdir) + }; + return resolved.parent().map(|p| p.to_path_buf()).or(Some(cur)); } return Some(cur); } @@ -143,7 +143,11 @@ mod windows_impl { /// Generates a unique named-pipe path used to communicate with the runner process. fn pipe_name(suffix: &str) -> String { let mut rng = SmallRng::from_entropy(); - format!(r"\\.\pipe\codex-runner-{:x}-{}", rng.gen::(), suffix) + format!( + r"\\.\pipe\codex-runner-{:x}-{}", + rng.r#gen::(), + suffix + ) } /// Creates a named pipe whose DACL only allows the sandbox user to connect. @@ -507,8 +511,8 @@ pub use windows_impl::run_windows_sandbox_capture; #[cfg(not(target_os = "windows"))] mod stub { use super::ElevatedSandboxCaptureRequest; - use anyhow::bail; use anyhow::Result; + use anyhow::bail; #[derive(Debug, Default)] pub struct CaptureResult { diff --git a/codex-rs/windows-sandbox-rs/src/env.rs b/codex-rs/windows-sandbox-rs/src/env.rs index c69a0033e9..c3f85cfffd 100644 --- a/codex-rs/windows-sandbox-rs/src/env.rs +++ b/codex-rs/windows-sandbox-rs/src/env.rs @@ -30,15 +30,15 @@ pub fn ensure_non_interactive_pager(env_map: &mut HashMap) { // Keep PATH and PATHEXT stable for callers that rely on inheriting the parent process env. pub fn inherit_path_env(env_map: &mut HashMap) { - if !env_map.contains_key("PATH") { - if let Ok(path) = env::var("PATH") { - env_map.insert("PATH".into(), path); - } + if !env_map.contains_key("PATH") + && let Ok(path) = env::var("PATH") + { + env_map.insert("PATH".into(), path); } - if !env_map.contains_key("PATHEXT") { - if let Ok(pathext) = env::var("PATHEXT") { - env_map.insert("PATHEXT".into(), pathext); - } + if !env_map.contains_key("PATHEXT") + && let Ok(pathext) = env::var("PATHEXT") + { + env_map.insert("PATHEXT".into(), pathext); } } diff --git a/codex-rs/windows-sandbox-rs/src/firewall.rs b/codex-rs/windows-sandbox-rs/src/firewall.rs index a95d3b57e2..c0caa165fc 100644 --- a/codex-rs/windows-sandbox-rs/src/firewall.rs +++ b/codex-rs/windows-sandbox-rs/src/firewall.rs @@ -4,25 +4,25 @@ use anyhow::Result; use std::fs::File; use std::io::Write; -use windows::core::Interface; -use windows::core::BSTR; use windows::Win32::Foundation::VARIANT_TRUE; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule3; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRules; -use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2; -use windows::Win32::NetworkManagement::WindowsFirewall::NetFwRule; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_ACTION_BLOCK; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_ANY; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_TCP; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_UDP; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_PROFILE2_ALL; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_RULE_DIR_OUT; +use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2; +use windows::Win32::NetworkManagement::WindowsFirewall::NetFwRule; +use windows::Win32::System::Com::CLSCTX_INPROC_SERVER; +use windows::Win32::System::Com::COINIT_APARTMENTTHREADED; use windows::Win32::System::Com::CoCreateInstance; use windows::Win32::System::Com::CoInitializeEx; use windows::Win32::System::Com::CoUninitialize; -use windows::Win32::System::Com::CLSCTX_INPROC_SERVER; -use windows::Win32::System::Com::COINIT_APARTMENTTHREADED; +use windows::core::BSTR; +use windows::core::Interface; use codex_windows_sandbox::SetupErrorCode; use codex_windows_sandbox::SetupFailure; @@ -40,8 +40,7 @@ const OFFLINE_BLOCK_LOOPBACK_TCP_RULE_FRIENDLY: &str = const OFFLINE_BLOCK_LOOPBACK_UDP_RULE_FRIENDLY: &str = "Codex Sandbox Offline - Block Loopback UDP"; const OFFLINE_PROXY_ALLOW_RULE_NAME: &str = "codex_sandbox_offline_allow_loopback_proxy"; const LOOPBACK_REMOTE_ADDRESSES: &str = "127.0.0.0/8,::1"; -const NON_LOOPBACK_REMOTE_ADDRESSES: &str = - "0.0.0.0-126.255.255.255,128.0.0.0-255.255.255.255,::,::2-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"; +const NON_LOOPBACK_REMOTE_ADDRESSES: &str = "0.0.0.0-126.255.255.255,128.0.0.0-255.255.255.255,::,::2-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"; struct BlockRuleSpec<'a> { internal_name: &'a str, diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index 37db3c59c2..375199f5cc 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -45,12 +45,12 @@ pub(crate) fn helper_bin_dir(codex_home: &Path) -> PathBuf { } pub(crate) fn legacy_lookup(kind: HelperExecutable) -> PathBuf { - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let candidate = dir.join(kind.file_name()); - if candidate.exists() { - return candidate; - } + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + let candidate = dir.join(kind.file_name()); + if candidate.exists() { + return candidate; } } PathBuf::from(kind.file_name()) @@ -377,4 +377,3 @@ mod tests { ); } } - diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 11afda750d..b0de8c2e02 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -1,3 +1,7 @@ +// Rust 2024 surfaces this lint across the crate; keep the edition bump separate +// from the eventual unsafe cleanup. +#![allow(unsafe_op_in_unsafe_fn)] + macro_rules! windows_modules { ($($name:ident),+ $(,)?) => { $(#[cfg(target_os = "windows")] mod $name;)+ @@ -70,10 +74,10 @@ pub use dpapi::protect as dpapi_protect; #[cfg(target_os = "windows")] pub use dpapi::unprotect as dpapi_unprotect; #[cfg(target_os = "windows")] -pub use elevated_impl::run_windows_sandbox_capture as run_windows_sandbox_capture_elevated; -#[cfg(target_os = "windows")] pub use elevated_impl::ElevatedSandboxCaptureRequest; #[cfg(target_os = "windows")] +pub use elevated_impl::run_windows_sandbox_capture as run_windows_sandbox_capture_elevated; +#[cfg(target_os = "windows")] pub use helper_materialization::resolve_current_exe_for_launch; #[cfg(target_os = "windows")] pub use hide_users::hide_current_user_profile_dir; @@ -84,15 +88,21 @@ pub use identity::require_logon_sandbox_creds; #[cfg(target_os = "windows")] pub use identity::sandbox_setup_is_complete; #[cfg(target_os = "windows")] -pub use logging::log_note; -#[cfg(target_os = "windows")] pub use logging::LOG_FILE_NAME; #[cfg(target_os = "windows")] +pub use logging::log_note; +#[cfg(target_os = "windows")] pub use path_normalization::canonicalize_path; #[cfg(target_os = "windows")] +pub use policy::SandboxPolicy; +#[cfg(target_os = "windows")] pub use policy::parse_policy; #[cfg(target_os = "windows")] -pub use policy::SandboxPolicy; +pub use process::PipeSpawnHandles; +#[cfg(target_os = "windows")] +pub use process::StderrMode; +#[cfg(target_os = "windows")] +pub use process::StdinMode; #[cfg(target_os = "windows")] pub use process::create_process_as_user; #[cfg(target_os = "windows")] @@ -100,11 +110,11 @@ pub use process::read_handle_loop; #[cfg(target_os = "windows")] pub use process::spawn_process_with_pipes; #[cfg(target_os = "windows")] -pub use process::PipeSpawnHandles; +pub use setup::SETUP_VERSION; #[cfg(target_os = "windows")] -pub use process::StderrMode; +pub use setup::SandboxSetupRequest; #[cfg(target_os = "windows")] -pub use process::StdinMode; +pub use setup::SetupRootOverrides; #[cfg(target_os = "windows")] pub use setup::run_elevated_setup; #[cfg(target_os = "windows")] @@ -118,11 +128,11 @@ pub use setup::sandbox_dir; #[cfg(target_os = "windows")] pub use setup::sandbox_secrets_dir; #[cfg(target_os = "windows")] -pub use setup::SandboxSetupRequest; +pub use setup_error::SetupErrorCode; #[cfg(target_os = "windows")] -pub use setup::SetupRootOverrides; +pub use setup_error::SetupErrorReport; #[cfg(target_os = "windows")] -pub use setup::SETUP_VERSION; +pub use setup_error::SetupFailure; #[cfg(target_os = "windows")] pub use setup_error::extract_failure as extract_setup_failure; #[cfg(target_os = "windows")] @@ -132,12 +142,6 @@ pub use setup_error::setup_error_path; #[cfg(target_os = "windows")] pub use setup_error::write_setup_error_report; #[cfg(target_os = "windows")] -pub use setup_error::SetupErrorCode; -#[cfg(target_os = "windows")] -pub use setup_error::SetupErrorReport; -#[cfg(target_os = "windows")] -pub use setup_error::SetupFailure; -#[cfg(target_os = "windows")] pub use token::convert_string_sid_to_sid; #[cfg(target_os = "windows")] pub use token::create_readonly_token_with_cap_from; @@ -148,14 +152,14 @@ pub use token::create_workspace_write_token_with_caps_from; #[cfg(target_os = "windows")] pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] +pub use windows_impl::CaptureResult; +#[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture; #[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture_with_extra_deny_write_paths; #[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] -pub use windows_impl::CaptureResult; -#[cfg(target_os = "windows")] pub use winutil::quote_windows_arg; #[cfg(target_os = "windows")] pub use winutil::string_from_sid_bytes; @@ -168,14 +172,14 @@ pub use workspace_acl::protect_workspace_agents_dir; #[cfg(target_os = "windows")] pub use workspace_acl::protect_workspace_codex_dir; +#[cfg(not(target_os = "windows"))] +pub use stub::CaptureResult; #[cfg(not(target_os = "windows"))] pub use stub::apply_world_writable_scan_and_denies; #[cfg(not(target_os = "windows"))] pub use stub::run_windows_sandbox_capture; #[cfg(not(target_os = "windows"))] pub use stub::run_windows_sandbox_legacy_preflight; -#[cfg(not(target_os = "windows"))] -pub use stub::CaptureResult; #[cfg(target_os = "windows")] mod windows_impl { @@ -183,8 +187,8 @@ mod windows_impl { use super::acl::add_deny_write_ace; use super::acl::allow_null_device; use super::acl::revoke_ace; - use super::allow::compute_allow_paths; use super::allow::AllowDenyPaths; + use super::allow::compute_allow_paths; use super::cap::load_or_create_cap_sids; use super::cap::workspace_cap_sid_for_cwd; use super::env::apply_no_network_to_env; @@ -194,8 +198,8 @@ mod windows_impl { use super::logging::log_start; use super::logging::log_success; use super::path_normalization::canonicalize_path; - use super::policy::parse_policy; use super::policy::SandboxPolicy; + use super::policy::parse_policy; use super::process::create_process_as_user; use super::token::convert_string_sid_to_sid; use super::token::create_workspace_write_token_with_caps_from; @@ -211,13 +215,13 @@ mod windows_impl { use std::ptr; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; - use windows_sys::Win32::Foundation::SetHandleInformation; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT; + use windows_sys::Win32::Foundation::SetHandleInformation; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::GetExitCodeProcess; - use windows_sys::Win32::System::Threading::WaitForSingleObject; use windows_sys::Win32::System::Threading::INFINITE; + use windows_sys::Win32::System::Threading::WaitForSingleObject; type PipeHandles = ((HANDLE, HANDLE), (HANDLE, HANDLE), (HANDLE, HANDLE)); @@ -355,15 +359,15 @@ mod windows_impl { }; unsafe { - if is_workspace_write { - if let Ok(base) = super::token::get_current_token_for_restriction() { - if let Ok(bytes) = super::token::get_logon_sid_bytes(base) { - let mut tmp = bytes.clone(); - let psid2 = tmp.as_mut_ptr() as *mut c_void; - allow_null_device(psid2); - } - windows_sys::Win32::Foundation::CloseHandle(base); + if is_workspace_write + && let Ok(base) = super::token::get_current_token_for_restriction() + { + if let Ok(bytes) = super::token::get_logon_sid_bytes(base) { + let mut tmp = bytes.clone(); + let psid2 = tmp.as_mut_ptr() as *mut c_void; + allow_null_device(psid2); } + windows_sys::Win32::Foundation::CloseHandle(base); } } @@ -384,23 +388,24 @@ mod windows_impl { } else { psid_generic }; - if let Ok(added) = add_allow_ace(p, psid) { - if added { - if persist_aces { - if p.is_dir() { - // best-effort seeding omitted intentionally - } - } else { - guards.push((p.clone(), psid)); + if let Ok(added) = add_allow_ace(p, psid) + && added + { + if persist_aces { + if p.is_dir() { + // best-effort seeding omitted intentionally } + } else { + guards.push((p.clone(), psid)); } } } for p in &deny { - if let Ok(added) = add_deny_write_ace(p, psid_generic) { - if added && !persist_aces { - guards.push((p.clone(), psid_generic)); - } + if let Ok(added) = add_deny_write_ace(p, psid_generic) + && added + && !persist_aces + { + guards.push((p.clone(), psid_generic)); } } allow_null_device(psid_generic); @@ -631,8 +636,8 @@ mod windows_impl { #[cfg(not(target_os = "windows"))] mod stub { - use anyhow::bail; use anyhow::Result; + use anyhow::bail; use codex_protocol::protocol::SandboxPolicy; use std::collections::HashMap; use std::path::Path; diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index f575ce3b8d..1c0d695f32 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -37,12 +37,11 @@ fn log_file_path(base_dir: &Path) -> Option { } fn append_line(line: &str, base_dir: Option<&Path>) { - if let Some(dir) = base_dir { - if let Some(path) = log_file_path(dir) { - if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(f, "{}", line); - } - } + if let Some(dir) = base_dir + && let Some(path) = log_file_path(dir) + && let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) + { + let _ = writeln!(f, "{line}"); } } diff --git a/codex-rs/windows-sandbox-rs/src/read_acl_mutex.rs b/codex-rs/windows-sandbox-rs/src/read_acl_mutex.rs index 0b3435fec1..643d70fa37 100644 --- a/codex-rs/windows-sandbox-rs/src/read_acl_mutex.rs +++ b/codex-rs/windows-sandbox-rs/src/read_acl_mutex.rs @@ -1,14 +1,14 @@ use anyhow::Result; use std::ffi::OsStr; use windows_sys::Win32::Foundation::CloseHandle; -use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::ERROR_ALREADY_EXISTS; use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; +use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::System::Threading::CreateMutexW; +use windows_sys::Win32::System::Threading::MUTEX_ALL_ACCESS; use windows_sys::Win32::System::Threading::OpenMutexW; use windows_sys::Win32::System::Threading::ReleaseMutex; -use windows_sys::Win32::System::Threading::MUTEX_ALL_ACCESS; use super::to_wide; diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs index e006b696fb..13460b9444 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/sandbox_users.rs @@ -1,27 +1,27 @@ #![cfg(target_os = "windows")] use anyhow::Result; -use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; -use rand::rngs::SmallRng; +use base64::engine::general_purpose::STANDARD as BASE64; use rand::RngCore; use rand::SeedableRng; +use rand::rngs::SmallRng; use serde::Serialize; -use std::ffi::c_void; use std::ffi::OsStr; +use std::ffi::c_void; use std::fs::File; use std::path::Path; use std::path::PathBuf; +use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::LocalFree; -use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; +use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_INFO_1; +use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_MEMBERS_INFO_3; use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success; use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAdd; use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAddMembers; use windows_sys::Win32::NetworkManagement::NetManagement::NetUserAdd; use windows_sys::Win32::NetworkManagement::NetManagement::NetUserSetInfo; -use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_INFO_1; -use windows_sys::Win32::NetworkManagement::NetManagement::LOCALGROUP_MEMBERS_INFO_3; use windows_sys::Win32::NetworkManagement::NetManagement::UF_DONT_EXPIRE_PASSWD; use windows_sys::Win32::NetworkManagement::NetManagement::UF_SCRIPT; use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1; @@ -34,14 +34,14 @@ use windows_sys::Win32::Security::LookupAccountNameW; use windows_sys::Win32::Security::LookupAccountSidW; use windows_sys::Win32::Security::SID_NAME_USE; +use codex_windows_sandbox::SETUP_VERSION; +use codex_windows_sandbox::SetupErrorCode; +use codex_windows_sandbox::SetupFailure; use codex_windows_sandbox::dpapi_protect; use codex_windows_sandbox::sandbox_dir; use codex_windows_sandbox::sandbox_secrets_dir; use codex_windows_sandbox::string_from_sid_bytes; use codex_windows_sandbox::to_wide; -use codex_windows_sandbox::SetupErrorCode; -use codex_windows_sandbox::SetupFailure; -use codex_windows_sandbox::SETUP_VERSION; pub const SANDBOX_USERS_GROUP: &str = "CodexSandboxUsers"; const SANDBOX_USERS_GROUP_COMMENT: &str = "Codex sandbox internal group (managed)"; diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 23022450a0..64e618ace1 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -189,15 +189,16 @@ pub fn sanitize_setup_metric_tag_value(value: &str) -> String { fn redact_home_paths(value: &str) -> String { let mut usernames: Vec = Vec::new(); - if let Ok(username) = std::env::var("USERNAME") { - if !username.trim().is_empty() { - usernames.push(username); - } + if let Ok(username) = std::env::var("USERNAME") + && !username.trim().is_empty() + { + usernames.push(username); } - if let Ok(user) = std::env::var("USER") { - if !user.trim().is_empty() && !usernames.iter().any(|v| v.eq_ignore_ascii_case(&user)) { - usernames.push(user); - } + if let Ok(user) = std::env::var("USER") + && !user.trim().is_empty() + && !usernames.iter().any(|v| v.eq_ignore_ascii_case(&user)) + { + usernames.push(user); } redact_username_segments(value, &usernames) diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs index 8a72b22a91..5696432853 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_main_win.rs @@ -4,8 +4,13 @@ mod firewall; use anyhow::Context; use anyhow::Result; -use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use codex_windows_sandbox::LOG_FILE_NAME; +use codex_windows_sandbox::SETUP_VERSION; +use codex_windows_sandbox::SetupErrorCode; +use codex_windows_sandbox::SetupErrorReport; +use codex_windows_sandbox::SetupFailure; use codex_windows_sandbox::canonicalize_path; use codex_windows_sandbox::convert_string_sid_to_sid; use codex_windows_sandbox::ensure_allow_mask_aces_with_inheritance; @@ -25,16 +30,11 @@ use codex_windows_sandbox::string_from_sid_bytes; use codex_windows_sandbox::to_wide; use codex_windows_sandbox::workspace_cap_sid_for_cwd; use codex_windows_sandbox::write_setup_error_report; -use codex_windows_sandbox::SetupErrorCode; -use codex_windows_sandbox::SetupErrorReport; -use codex_windows_sandbox::SetupFailure; -use codex_windows_sandbox::LOG_FILE_NAME; -use codex_windows_sandbox::SETUP_VERSION; use serde::Deserialize; use serde::Serialize; use std::collections::HashSet; -use std::ffi::c_void; use std::ffi::OsStr; +use std::ffi::c_void; use std::fs::File; use std::io::Write; use std::os::windows::process::CommandExt; @@ -44,17 +44,17 @@ use std::process::Command; use std::process::Stdio; use std::sync::mpsc; use windows_sys::Win32::Foundation::GetLastError; -use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Security::ACL; use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW; -use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; -use windows_sys::Win32::Security::Authorization::SetNamedSecurityInfoW; use windows_sys::Win32::Security::Authorization::EXPLICIT_ACCESS_W; use windows_sys::Win32::Security::Authorization::GRANT_ACCESS; use windows_sys::Win32::Security::Authorization::SE_FILE_OBJECT; +use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; +use windows_sys::Win32::Security::Authorization::SetNamedSecurityInfoW; use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_SID; use windows_sys::Win32::Security::Authorization::TRUSTEE_W; -use windows_sys::Win32::Security::ACL; use windows_sys::Win32::Security::CONTAINER_INHERIT_ACE; use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; use windows_sys::Win32::Security::OBJECT_INHERIT_ACE; diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 962a53d60c..f6583e0de4 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -10,22 +10,22 @@ use std::path::PathBuf; use std::process::Command; use std::process::Stdio; -use crate::allow::compute_allow_paths; use crate::allow::AllowDenyPaths; +use crate::allow::compute_allow_paths; use crate::helper_materialization::helper_bin_dir; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::policy::SandboxPolicy; +use crate::setup_error::SetupErrorCode; +use crate::setup_error::SetupFailure; use crate::setup_error::clear_setup_error_report; use crate::setup_error::failure; use crate::setup_error::read_setup_error_report; -use crate::setup_error::SetupErrorCode; -use crate::setup_error::SetupFailure; -use anyhow::anyhow; use anyhow::Context; use anyhow::Result; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use anyhow::anyhow; use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; @@ -332,10 +332,10 @@ fn profile_read_roots(user_profile: &Path) -> Vec { fn gather_helper_read_roots(codex_home: &Path) -> Vec { let mut roots = Vec::new(); - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - roots.push(dir.to_path_buf()); - } + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + roots.push(dir.to_path_buf()); } let helper_dir = helper_bin_dir(codex_home); let _ = std::fs::create_dir_all(&helper_dir); @@ -503,10 +503,10 @@ pub(crate) fn offline_proxy_settings_from_env( pub(crate) fn proxy_ports_from_env(env_map: &HashMap) -> Vec { let mut ports = BTreeSet::new(); for key in PROXY_ENV_KEYS { - if let Some(value) = env_map.get(*key) { - if let Some(port) = loopback_proxy_port_from_url(value) { - ports.insert(port); - } + if let Some(value) = env_map.get(*key) + && let Some(port) = loopback_proxy_port_from_url(value) + { + ports.insert(port); } } ports.into_iter().collect() @@ -570,12 +570,12 @@ fn quote_arg(arg: &str) -> String { } fn find_setup_exe() -> PathBuf { - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let candidate = dir.join("codex-windows-sandbox-setup.exe"); - if candidate.exists() { - return candidate; - } + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + let candidate = dir.join("codex-windows-sandbox-setup.exe"); + if candidate.exists() { + return candidate; } } PathBuf::from("codex-windows-sandbox-setup.exe") @@ -606,11 +606,11 @@ fn run_setup_exe( codex_home: &Path, ) -> Result<()> { use windows_sys::Win32::System::Threading::GetExitCodeProcess; - use windows_sys::Win32::System::Threading::WaitForSingleObject; use windows_sys::Win32::System::Threading::INFINITE; - use windows_sys::Win32::UI::Shell::ShellExecuteExW; + use windows_sys::Win32::System::Threading::WaitForSingleObject; use windows_sys::Win32::UI::Shell::SEE_MASK_NOCLOSEPROCESS; use windows_sys::Win32::UI::Shell::SHELLEXECUTEINFOW; + use windows_sys::Win32::UI::Shell::ShellExecuteExW; let exe = find_setup_exe(); let payload_json = serde_json::to_string(payload).map_err(|err| { failure( @@ -801,13 +801,13 @@ fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> V #[cfg(test)] mod tests { + use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS; use super::gather_legacy_full_read_roots; use super::gather_read_roots; use super::loopback_proxy_port_from_url; use super::offline_proxy_settings_from_env; use super::profile_read_roots; use super::proxy_ports_from_env; - use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS; use crate::helper_materialization::helper_bin_dir; use crate::policy::SandboxPolicy; use codex_protocol::protocol::ReadOnlyAccess; @@ -1020,8 +1020,10 @@ mod tests { let policy = SandboxPolicy::ReadOnly { access: ReadOnlyAccess::Restricted { include_platform_defaults: false, - readable_roots: vec![AbsolutePathBuf::from_absolute_path(&readable_root) - .expect("absolute readable root")], + readable_roots: vec![ + AbsolutePathBuf::from_absolute_path(&readable_root) + .expect("absolute readable root"), + ], }, network_access: false, }; @@ -1036,9 +1038,11 @@ mod tests { assert!(roots.contains(&expected_helper)); assert!(roots.contains(&expected_cwd)); assert!(roots.contains(&expected_readable)); - assert!(canonical_windows_platform_default_roots() - .into_iter() - .all(|path| !roots.contains(&path))); + assert!( + canonical_windows_platform_default_roots() + .into_iter() + .all(|path| !roots.contains(&path)) + ); } #[test] @@ -1057,9 +1061,11 @@ mod tests { let roots = gather_read_roots(&command_cwd, &policy, &codex_home); - assert!(canonical_windows_platform_default_roots() - .into_iter() - .all(|path| roots.contains(&path))); + assert!( + canonical_windows_platform_default_roots() + .into_iter() + .all(|path| roots.contains(&path)) + ); } #[test] @@ -1071,8 +1077,10 @@ mod tests { fs::create_dir_all(&command_cwd).expect("create workspace"); fs::create_dir_all(&writable_root).expect("create writable root"); let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(&writable_root) - .expect("absolute writable root")], + writable_roots: vec![ + AbsolutePathBuf::from_absolute_path(&writable_root) + .expect("absolute writable root"), + ], read_only_access: ReadOnlyAccess::Restricted { include_platform_defaults: false, readable_roots: Vec::new(), @@ -1099,8 +1107,10 @@ mod tests { let roots = gather_legacy_full_read_roots(&command_cwd, &policy, &codex_home); - assert!(canonical_windows_platform_default_roots() - .into_iter() - .all(|path| roots.contains(&path))); + assert!( + canonical_windows_platform_default_roots() + .into_iter() + .all(|path| roots.contains(&path)) + ); } } diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index 0cc9f59da4..56d97ff01e 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -130,7 +130,7 @@ pub unsafe fn world_sid() -> Result> { /// Caller is responsible for freeing the returned SID with `LocalFree`. pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { #[link(name = "advapi32")] - extern "system" { + unsafe extern "system" { fn ConvertStringSidToSidW(StringSid: *const u16, Sid: *mut *mut c_void) -> i32; } let mut psid: *mut c_void = std::ptr::null_mut(); @@ -153,7 +153,7 @@ pub unsafe fn get_current_token_for_restriction() -> Result { | TOKEN_ADJUST_PRIVILEGES; let mut h: HANDLE = 0; #[link(name = "advapi32")] - extern "system" { + unsafe extern "system" { fn OpenProcessToken( ProcessHandle: HANDLE, DesiredAccess: u32, From 6a0c4709ca2154e9f3ebb07e58fb156386630188 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 27 Mar 2026 11:30:22 +0100 Subject: [PATCH 013/185] feat: spawn v2 make task name as mandatory (#15986) --- .../src/tools/handlers/multi_agents_tests.rs | 34 +++++++++++++++++++ .../tools/handlers/multi_agents_v2/spawn.rs | 16 +++++---- codex-rs/core/src/tools/spec.rs | 10 +++--- codex-rs/core/src/tools/spec_tests.rs | 2 +- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e57491762c..7f1f966569 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -321,6 +321,40 @@ async fn spawn_agent_returns_agent_id_without_task_name() { assert_eq!(success, Some(true)); } +#[tokio::test] +async fn multi_agent_v2_spawn_requires_task_name() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo" + })), + ); + let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else { + panic!("missing task_name should be rejected"); + }; + let FunctionCallError::RespondToModel(message) = err else { + panic!("missing task_name should surface as a model-facing error"); + }; + assert!(message.contains("missing field `task_name`")); +} + #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index d67be936ec..6cdad3c1c2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -83,7 +83,7 @@ impl ToolHandler for Handler { &turn.session_source, child_depth, role_name, - args.task_name.clone(), + Some(args.task_name.clone()), )?), SpawnAgentOptions { fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), @@ -132,7 +132,6 @@ impl ToolHandler for Handler { .and_then(|snapshot| snapshot.reasoning_effort) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); - let task_name = new_agent_path.clone(); session .send_event( &turn, @@ -150,16 +149,21 @@ impl ToolHandler for Handler { .into(), ) .await; - let new_thread_id = result?.thread_id; + let _ = result?; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); turn.session_telemetry.counter( "codex.multi_agent.spawn", /*inc*/ 1, &[("role", role_tag)], ); + let task_name = new_agent_path.ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawned agent is missing a canonical task name".to_string(), + ) + })?; Ok(SpawnAgentResult { - agent_id: task_name.is_none().then(|| new_thread_id.to_string()), + agent_id: None, task_name, nickname, }) @@ -170,7 +174,7 @@ impl ToolHandler for Handler { struct SpawnAgentArgs { message: Option, items: Option>, - task_name: Option, + task_name: String, agent_type: Option, model: Option, reasoning_effort: Option, @@ -181,7 +185,7 @@ struct SpawnAgentArgs { #[derive(Debug, Serialize)] pub(crate) struct SpawnAgentResult { agent_id: Option, - task_name: Option, + task_name: String, nickname: Option, } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index ea85eaacdd..1a2f16b5c8 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -157,10 +157,10 @@ fn spawn_agent_output_schema_v2() -> JsonValue { "properties": { "agent_id": { "type": ["string", "null"], - "description": "Thread identifier for the spawned agent when no task name was assigned." + "description": "Legacy thread identifier for the spawned agent." }, "task_name": { - "type": ["string", "null"], + "type": "string", "description": "Canonical task name for the spawned agent." }, "nickname": { @@ -1215,13 +1215,13 @@ fn create_spawn_agent_tool_v1(config: &ToolsConfig) -> ToolSpec { fn create_spawn_agent_tool_v2(config: &ToolsConfig) -> ToolSpec { let available_models_description = spawn_agent_models_description(&config.available_models); - let return_value_description = "Returns the canonical task name when the spawned agent was named, otherwise the agent id, plus the user-facing nickname when available."; + let return_value_description = "Returns the canonical task name for the spawned agent, plus the user-facing nickname when available."; let mut properties = spawn_agent_common_properties(config); properties.insert( "task_name".to_string(), JsonSchema::String { description: Some( - "Optional task name for the new agent. Use lowercase letters, digits, and underscores." + "Task name for the new agent. Use lowercase letters, digits, and underscores." .to_string(), ), }, @@ -1237,7 +1237,7 @@ fn create_spawn_agent_tool_v2(config: &ToolsConfig) -> ToolSpec { defer_loading: None, parameters: JsonSchema::Object { properties, - required: None, + required: Some(vec!["task_name".to_string()]), additional_properties: Some(false.into()), }, output_schema: Some(spawn_agent_output_schema_v2()), diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 5b9016ec6a..b7b9f001e5 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -454,7 +454,7 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { panic!("spawn_agent should use object params"); }; assert!(properties.contains_key("task_name")); - assert_eq!(required.as_ref(), None); + assert_eq!(required.as_ref(), Some(&vec!["task_name".to_string()])); let output_schema = output_schema .as_ref() .expect("spawn_agent should define output schema"); From 270b7655cd46e8f7573aea122f87b619b225a754 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 27 Mar 2026 14:49:45 +0100 Subject: [PATCH 014/185] Use codex-utils-template for login error page (#16000) --- codex-rs/Cargo.lock | 1 + codex-rs/login/Cargo.toml | 1 + codex-rs/login/src/assets/error.html | 10 ++--- codex-rs/login/src/server.rs | 60 ++++++++++++++++++++++++---- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 17d5c35e83..03e8dc5b63 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2224,6 +2224,7 @@ dependencies = [ "codex-keyring-store", "codex-protocol", "codex-terminal-detection", + "codex-utils-template", "core_test_support", "keyring", "once_cell", diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 7fd7815281..92ed39a6ce 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -17,6 +17,7 @@ codex-config = { workspace = true } codex-keyring-store = { workspace = true } codex-protocol = { workspace = true } codex-terminal-detection = { workspace = true } +codex-utils-template = { workspace = true } once_cell = { workspace = true } os_info = { workspace = true } rand = { workspace = true } diff --git a/codex-rs/login/src/assets/error.html b/codex-rs/login/src/assets/error.html index 96c1eb303c..e5fb4dba10 100644 --- a/codex-rs/login/src/assets/error.html +++ b/codex-rs/login/src/assets/error.html @@ -101,21 +101,21 @@

Codex login
-

__ERROR_TITLE__

-

__ERROR_MESSAGE__

+

{{error_title}}

+

{{error_message}}

Error code - __ERROR_CODE__ + {{error_code}}
Details - __ERROR_DESCRIPTION__ + {{error_description}}
-

__ERROR_HELP__

+

{{error_help}}

diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b726eeed8f..c811fa36df 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -20,6 +20,7 @@ use std::net::TcpStream; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::LazyLock; use std::thread; use std::time::Duration; @@ -35,6 +36,7 @@ use base64::Engine; use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_client::build_reqwest_client_with_custom_ca; +use codex_utils_template::Template; use rand::RngCore; use serde_json::Value as JsonValue; use tiny_http::Header; @@ -48,6 +50,10 @@ use tracing::warn; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; +static LOGIN_ERROR_PAGE_TEMPLATE: LazyLock