From dd30c8eedd171d2dda71c43fac27dc42f457da5f Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 26 Mar 2026 23:17:59 -0700 Subject: [PATCH 01/89] 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 02/89] 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 03/89] 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 04/89] 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 05/89] 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 06/89] 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