From 8ba718a6113d543f36c08b928247c3d0aa772e8f Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 6 Mar 2026 15:32:53 -0800 Subject: [PATCH 01/13] docs: remove auth login logging plan (#13810) ## Summary Remove `docs/auth-login-logging-plan.md`. ## Why The document was a temporary planning artifact. The durable rationale for the auth-login diagnostics work now lives in the code comments, tests, PR context, and existing implementation notes, so keeping the standalone plan doc adds duplicate maintenance surface. ## Testing - not run (docs-only deletion) Co-authored-by: Codex --- docs/auth-login-logging-plan.md | 193 -------------------------------- 1 file changed, 193 deletions(-) delete mode 100644 docs/auth-login-logging-plan.md diff --git a/docs/auth-login-logging-plan.md b/docs/auth-login-logging-plan.md deleted file mode 100644 index ac91c0769d..0000000000 --- a/docs/auth-login-logging-plan.md +++ /dev/null @@ -1,193 +0,0 @@ -# Auth Login Logging - -## Problem - -Customer-side auth failures are hard to diagnose because the most important browser-login step, -the final `POST https://auth.openai.com/oauth/token` after the localhost callback, historically -does not show up as a first-class application event. - -In the failing HARs and Slack thread, browser auth succeeds, workspace selection succeeds, and the -browser reaches `http://localhost:1455/auth/callback`. Support can usually confirm that: - -- the user reached the browser sign-in flow -- the browser returned to the localhost callback -- Codex showed a generic sign-in failure - -What support cannot reliably determine from Codex-owned logs is why the final token exchange -failed. That leaves the most important diagnostic question unanswered: - -- was this a backend non-2xx response -- a transport failure talking to `auth.openai.com` -- a proxy, TLS, DNS, or connectivity issue -- some other local client-side failure after browser auth completed - -This documentation explains how the current instrumentation closes that gap without broadening the -normal logging surface in unsafe ways. - -## Mental Model - -The browser-login flow has three separate outputs, and they do not serve the same audience: - -- the browser-facing error page -- the caller-visible returned `io::Error` -- the normal structured application log - -Those outputs now intentionally diverge. - -The browser-facing page and returned error still preserve the backend detail needed by developers, -sysadmins, and support engineers to understand what happened. The structured log stays narrower: -it emits explicitly reviewed fields, redacted URLs, and redacted transport errors so the normal -log file is useful without becoming a credential sink. - -## Non-goals - -This does not add auth logging to every runtime request. - -- The instrumentation is scoped to the initial browser-login callback flow. -- The refresh-token path in `codex-core` remains a separate concern. -- This does not attempt to classify every transport failure into a specific root cause from string - matching. - -## Tradeoffs - -This implementation prefers fidelity for caller-visible errors and restraint for structured logs. - -- Non-2xx token endpoint responses log parsed safe fields such as status, `error`, and - `error_description` when available. -- Non-JSON token endpoint bodies are preserved in the returned error so CLI/browser flows still - surface the backend detail that operators need. -- The callback-layer structured log does not log `%err` for token endpoint failures, because that - would persist arbitrary backend response text into the normal log file. -- Transport failures keep the underlying `reqwest` error text, but attached URLs are redacted - before they are logged or returned. -- Caller-supplied issuer URLs are sanitized before they are logged, including custom issuers with - embedded credentials or sensitive query params. - -The result is not maximally detailed in one place. It is intentionally split so each surface gets -the level of detail it can safely carry. - -## Architecture - -The browser-login callback flow lives in -[`codex-rs/login/src/server.rs`](../codex-rs/login/src/server.rs). - -The key behavior is: - -- the callback handler logs whether the callback was received and whether state validation passed -- the token exchange logs start, success, and non-2xx responses as structured events -- transport failures log the redacted `reqwest` error plus `is_timeout`, `is_connect`, and - `is_request` -- the browser-facing `Codex Sign-in Error` page remains intact -- the returned `io::Error` continues to carry useful backend detail for CLI/browser callers - -App-server consumers use the same login-server path rather than a separate auth implementation. - -- `account/login/start` calls into `run_login_server(...)` -- app-server waits for `server.block_until_done()` -- app-server emits `account/login/completed` with wrapped success/error state - -That means the login-crate instrumentation benefits: - -- direct CLI / TUI login -- Electron app login -- VS Code extension login - -Direct `codex login` also writes a small file-backed log through the CLI crate. - -- the file is `codex-login.log` under the configured `log_dir` -- this uses a deliberately small tracing setup local to the CLI login commands -- it does not try to reuse the TUI logging stack wholesale, because the TUI path also installs - feedback, OpenTelemetry, and other interactive-session layers that are not needed for a - one-shot login command -- the duplication is intentional: it keeps the direct CLI behavior easy to reason about while - still giving support a durable artifact from the same `codex_login::server` events - -## Observability - -The main new signals are emitted from the `login` crate target, for example -`codex_login::server`, so they stay aligned with the code that produces them. - -The useful events are: - -- callback received -- callback state mismatch -- OAuth callback returned error -- OAuth token exchange started -- OAuth token exchange transport failure -- OAuth token exchange returned non-success status -- OAuth token exchange succeeded - -The structured log intentionally uses a narrower payload than the returned error: - -- issuer URLs are sanitized before logging -- sensitive URL query keys such as `code`, `state`, `token`, `access_token`, `refresh_token`, - `id_token`, `client_secret`, and `code_verifier` are redacted -- embedded credentials and fragments are stripped from logged URLs -- parsed token-endpoint fields are logged individually when available -- arbitrary non-JSON token endpoint bodies are not logged into the normal application log - -This split is the main privacy boundary in the implementation. - -## Failure Modes - -The current instrumentation is most useful for these cases: - -- browser auth succeeds but the final token exchange fails -- custom issuer deployments need confirmation that the callback reached the login server -- operators need to distinguish backend non-2xx responses from transport failures -- transport failures need the underlying `reqwest` signal without leaking sensitive URL parts - -It is intentionally weaker for one class of diagnosis: - -- it does not try to infer specific transport causes such as proxy, TLS, or DNS from message - string matching, because that kind of over-classification can mislead operators - -## Security and Sensitivity Notes - -This implementation treats the normal application log as a persistent surface that must be safe to -collect and share. - -That means: - -- user-supplied issuer URLs are sanitized before logging -- transport errors redact attached URLs instead of dropping them entirely -- known secret-bearing query params are redacted surgically rather than removing all URL context -- non-JSON token endpoint bodies are preserved only for the returned error path, not the - structured-log path - -This behavior reflects two review-driven constraints that are already fixed in the code: - -- custom issuers no longer leak embedded credentials or sensitive query params in the - `starting oauth token exchange` log line -- non-JSON token endpoint bodies are once again preserved for caller-visible errors, but they no - longer get duplicated into normal structured logs through callback-layer `%err` logging - -## Debug Path - -For a failed sign-in, read the evidence in this order: - -1. Browser/HAR evidence: - confirm the browser reached `http://localhost:1455/auth/callback`. -2. Login-crate structured logs: - check whether the callback was received, whether state validation passed, and whether the token - exchange failed as transport or non-2xx. -3. Caller-visible error: - use the CLI/browser/app error text to recover backend detail that is intentionally not copied - into the normal log file. -4. App-server wrapper: - if the flow runs through app-server, use `account/login/completed` and its wrapped - `Login server error: ...` result as the client-facing envelope around the same login-crate - behavior. - -The most important invariant is simple: browser success does not imply login success. The native -client still has to exchange the auth code successfully after the callback arrives. - -## Tests - -The `codex-login` test suite covers the new redaction and parsing boundaries: - -- parsed token endpoint JSON fields still surface correctly -- plain-text token endpoint bodies still remain available to the caller-visible error path -- sensitive query values are redacted selectively -- URL shape is preserved while credentials, fragments, and known secret-bearing params are removed -- issuer sanitization redacts custom issuer credentials and sensitive params before logging From f82678b2a466b3e6e30b8f1eb5ca2dbae528daa7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 6 Mar 2026 15:39:13 -0800 Subject: [PATCH 02/13] config: add initial support for the new permission profile config language in config.toml (#13434) ## Why `SandboxPolicy` currently mixes together three separate concerns: - parsing layered config from `config.toml` - representing filesystem sandbox state - carrying basic network policy alongside filesystem choices That makes the existing config awkward to extend and blocks the new TOML proposal where `[permissions]` becomes a table of named permission profiles selected by `default_permissions`. (The idea is that if `default_permissions` is not specified, we assume the user is opting into the "traditional" way to configure the sandbox.) This PR adds the config-side plumbing for those profiles while still projecting back to the legacy `SandboxPolicy` shape that the current macOS and Linux sandbox backends consume. It also tightens the filesystem profile model so scoped entries only exist for `:project_roots`, and so nested keys must stay within a project root instead of using `.` or `..` traversal. This drops support for the short-lived `[permissions.network]` in `config.toml` because now that would be interpreted as a profile named `network` within `[permissions]`. ## What Changed - added `PermissionsToml`, `PermissionProfileToml`, `FilesystemPermissionsToml`, and `FilesystemPermissionToml` so config can parse named profiles under `[permissions..filesystem]` - added top-level `default_permissions` selection, validation for missing or unknown profiles, and compilation from a named profile into split `FileSystemSandboxPolicy` and `NetworkSandboxPolicy` values - taught config loading to choose between the legacy `sandbox_mode` path and the profile-based path without breaking legacy users - introduced `codex-protocol::permissions` for the split filesystem and network sandbox types, and stored those alongside the legacy projected `sandbox_policy` in runtime `Permissions` - modeled `FileSystemSpecialPath` so only `ProjectRoots` can carry a nested `subpath`, matching the intended config syntax instead of allowing invalid states for other special paths - restricted scoped filesystem maps to `:project_roots`, with validation that nested entries are non-empty descendant paths and cannot use `.` or `..` to escape the project root - kept existing runtime consumers working by projecting `FileSystemSandboxPolicy` back into `SandboxPolicy`, with an explicit error for profiles that request writes outside the workspace root - loaded proxy settings from top-level `[network]` - regenerated `core/config.schema.json` ## Verification - added config coverage for profile deserialization, `default_permissions` selection, top-level `[network]` loading, network enablement, rejection of writes outside the workspace root, rejection of nested entries for non-`:project_roots` special paths, and rejection of parent-directory traversal in `:project_roots` maps - added protocol coverage for the legacy bridge rejecting non-workspace writes ## Docs - update the Codex config docs on developers.openai.com/codex to document named `[permissions.]` entries, `default_permissions`, scoped `:project_roots` syntax, the descendant-path restriction for nested `:project_roots` entries, and top-level `[network]` proxy configuration --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13434). * #13453 * #13452 * #13451 * #13449 * #13448 * #13445 * #13440 * #13439 * __->__ #13434 --- codex-rs/core/config.schema.json | 45 +- codex-rs/core/src/config/config_tests.rs | 485 ++++++++++++++++-- codex-rs/core/src/config/mod.rs | 245 +++++++-- codex-rs/core/src/config/permissions.rs | 225 +++++++- codex-rs/core/src/network_proxy_loader.rs | 70 +-- .../runtimes/shell/unix_escalation_tests.rs | 4 + codex-rs/core/tests/suite/approvals.rs | 7 +- codex-rs/network-proxy/README.md | 6 +- codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/permissions.rs | 473 +++++++++++++++++ codex-rs/protocol/src/protocol.rs | 35 ++ 11 files changed, 1472 insertions(+), 124 deletions(-) create mode 100644 codex-rs/protocol/src/permissions.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f94469530..34239c0c9d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -568,6 +568,30 @@ }, "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "none", + "read", + "write" + ], + "type": "string" + }, + "FilesystemPermissionToml": { + "anyOf": [ + { + "$ref": "#/definitions/FileSystemAccessMode" + }, + { + "additionalProperties": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "type": "object" + } + ] + }, + "FilesystemPermissionsToml": { + "type": "object" + }, "ForcedLoginMethod": { "enum": [ "chatgpt", @@ -1089,20 +1113,21 @@ }, "type": "object" }, - "PermissionsToml": { + "PermissionProfileToml": { "additionalProperties": false, "properties": { + "filesystem": { + "$ref": "#/definitions/FilesystemPermissionsToml" + }, "network": { - "allOf": [ - { - "$ref": "#/definitions/NetworkToml" - } - ], - "description": "Network proxy settings from `[permissions.network]`. User config can enable the proxy; managed requirements may still constrain values." + "$ref": "#/definitions/NetworkToml" } }, "type": "object" }, + "PermissionsToml": { + "type": "object" + }, "Personality": { "enum": [ "none", @@ -1664,6 +1689,10 @@ "description": "Compact prompt used for history compaction.", "type": "string" }, + "default_permissions": { + "description": "Default named permissions profile to apply from the `[permissions]` table.", + "type": "string" + }, "developer_instructions": { "default": null, "description": "Developer instructions inserted as a `developer` role message.", @@ -2067,7 +2096,7 @@ } ], "default": null, - "description": "Nested permissions settings." + "description": "Named permissions profiles." }, "personality": { "allOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8fa95d6a29..e70f7c2b20 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -13,6 +13,10 @@ use crate::config_loader::RequirementSource; use crate::features::Feature; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; use serde::Deserialize; use tempfile::tempdir; @@ -197,9 +201,18 @@ fn runtime_config_defaults_model_availability_nux() { } #[test] -fn config_toml_deserializes_permissions_network() { +fn config_toml_deserializes_permission_profiles() { let toml = r#" -[permissions.network] +default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.filesystem.":project_roots"] +"." = "write" +"docs" = "read" + +[permissions.workspace.network] enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false @@ -207,55 +220,92 @@ allow_upstream_proxy = false allowed_domains = ["openai.com"] "#; let cfg: ConfigToml = - toml::from_str(toml).expect("TOML deserialization should succeed for permissions.network"); + toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); + assert_eq!(cfg.default_permissions.as_deref(), Some("workspace")); assert_eq!( - cfg.permissions - .and_then(|permissions| permissions.network) - .expect("permissions.network should deserialize"), - NetworkToml { - enabled: Some(true), - proxy_url: Some("http://127.0.0.1:43128".to_string()), - enable_socks5: Some(false), - socks_url: None, - enable_socks5_udp: None, - allow_upstream_proxy: Some(false), - 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, - allow_local_binding: None, + cfg.permissions.expect("[permissions] should deserialize"), + PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([ + ( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ), + ( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + (".".to_string(), FileSystemAccessMode::Write), + ("docs".to_string(), FileSystemAccessMode::Read), + ])), + ), + ]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + socks_url: None, + enable_socks5_udp: None, + allow_upstream_proxy: Some(false), + 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, + allow_local_binding: None, + }), + }, + )]), } ); } #[test] -fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io::Result<()> { +fn permissions_profiles_network_populates_runtime_network_proxy_spec() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let cfg = ConfigToml { - permissions: Some(PermissionsToml { - network: Some(NetworkToml { - enabled: Some(true), - proxy_url: Some("http://127.0.0.1:43128".to_string()), - enable_socks5: Some(false), - ..Default::default() - }), - }), - ..Default::default() - }; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let config = Config::load_from_base_config_with_overrides( - cfg, - ConfigOverrides::default(), + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, codex_home.path().to_path_buf(), )?; let network = config .permissions .network .as_ref() - .expect("enabled permissions.network should produce a NetworkProxySpec"); + .expect("enabled profile network should produce a NetworkProxySpec"); assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); assert!(!network.socks_enabled()); @@ -263,24 +313,357 @@ fn permissions_network_enabled_populates_runtime_network_proxy_spec() -> std::io } #[test] -fn permissions_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> { +fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let cfg = ConfigToml { - permissions: Some(PermissionsToml { - network: Some(NetworkToml { - allowed_domains: Some(vec!["openai.com".to_string()]), - ..Default::default() + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + allowed_domains: Some(vec!["openai.com".to_string()]), + ..Default::default() + }), + }, + )]), }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + assert!(config.permissions.network.is_none()); + Ok(()) +} + +#[test] +fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::create_dir_all(cwd.path().join("docs"))?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let cfg = ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([ + ( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + ), + ( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + (".".to_string(), FileSystemAccessMode::Write), + ("docs".to_string(), FileSystemAccessMode::Read), + ])), + ), + ]), + }), + network: None, + }, + )]), }), ..Default::default() }; let config = Config::load_from_base_config_with_overrides( cfg, - ConfigOverrides::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, codex_home.path().to_path_buf(), )?; - assert!(config.permissions.network.is_none()); + + let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")).unwrap(); + assert_eq!( + config.permissions.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some("docs".into())), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: memories_root.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]), + ); + assert_eq!( + config.permissions.sandbox_policy.get(), + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![memories_root], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![ + AbsolutePathBuf::try_from(cwd.path().join("docs")).expect("absolute docs path"), + ], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); + assert_eq!( + config.permissions.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); + Ok(()) +} + +#[test] +fn permissions_profiles_require_default_permissions() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("missing default_permissions should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "config defines `[permissions]` profiles but does not set `default_permissions`" + ); + Ok(()) +} + +#[test] +fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + let external_write_path = if cfg!(windows) { r"C:\temp" } else { "/tmp" }; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + external_write_path.to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Write), + )]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("writes outside the workspace root should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("filesystem writes outside the workspace root"), + "{err}" + ); + Ok(()) +} + +#[test] +fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([( + "docs".to_string(), + FileSystemAccessMode::Read, + )])), + )]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("nested entries outside :project_roots should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "filesystem path `:minimal` does not support nested entries" + ); + Ok(()) +} + +#[test] +fn permissions_profiles_reject_project_root_parent_traversal() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":project_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([( + "../sibling".to_string(), + FileSystemAccessMode::Read, + )])), + )]), + }), + network: None, + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + ) + .expect_err("parent traversal should be rejected for project root subpaths"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "filesystem subpath `../sibling` must be a descendant path without `.` or `..` components" + ); + Ok(()) +} + +#[test] +fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + assert!( + config.permissions.network_sandbox_policy.is_enabled(), + "expected network sandbox policy to be enabled", + ); + assert!( + config + .permissions + .sandbox_policy + .get() + .has_full_network_access() + ); Ok(()) } @@ -2653,6 +3036,10 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -2782,6 +3169,10 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -2909,6 +3300,10 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -3022,6 +3417,10 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e489720bab..f4a3063e9c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -27,6 +27,7 @@ use crate::config::types::WindowsSandboxModeToml; use crate::config::types::WindowsToml; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConstrainedWithSource; use crate::config_loader::LoaderOverrides; @@ -72,6 +73,8 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -86,8 +89,10 @@ use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; -use crate::config::permissions::network_proxy_config_from_permissions; +use crate::config::permissions::compile_permission_profile; +use crate::config::permissions::network_proxy_config_from_profile_network; use crate::config::profile::ConfigProfile; +use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; @@ -107,8 +112,12 @@ pub use codex_network_proxy::NetworkProxyAuditMetadata; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub use permissions::FilesystemPermissionToml; +pub use permissions::FilesystemPermissionsToml; pub use permissions::NetworkToml; +pub use permissions::PermissionProfileToml; pub use permissions::PermissionsToml; +pub(crate) use permissions::resolve_permission_profile; pub use service::ConfigService; pub use service::ConfigServiceError; @@ -137,11 +146,9 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { Some(resolved_cwd.join(path)) } } - #[cfg(test)] pub(crate) fn test_config() -> Config { - use tempfile::tempdir; - let codex_home = tempdir().expect("create temp dir"); + let codex_home = tempfile::tempdir().expect("create temp dir"); Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), @@ -157,6 +164,12 @@ pub struct Permissions { pub approval_policy: Constrained, /// Effective sandbox policy used for shell/unified exec. pub sandbox_policy: Constrained, + /// Effective filesystem sandbox policy, including entries that cannot yet + /// be fully represented by the legacy [`SandboxPolicy`] projection. + pub file_system_sandbox_policy: FileSystemSandboxPolicy, + /// Effective network sandbox policy split out from the legacy + /// [`SandboxPolicy`] projection. + pub network_sandbox_policy: NetworkSandboxPolicy, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -1045,7 +1058,11 @@ pub struct ConfigToml { /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. pub sandbox_workspace_write: Option, - /// Nested permissions settings. + /// Default named permissions profile to apply from the `[permissions]` + /// table. + pub default_permissions: Option, + + /// Named permissions profiles. #[serde(default)] pub permissions: Option, @@ -1563,6 +1580,78 @@ impl ConfigToml { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PermissionConfigSyntax { + Legacy, + Profiles, +} + +#[derive(Debug, Deserialize, Default)] +struct PermissionSelectionToml { + default_permissions: Option, + sandbox_mode: Option, +} + +fn resolve_permission_config_syntax( + config_layer_stack: &ConfigLayerStack, + cfg: &ConfigToml, + sandbox_mode_override: Option, + profile_sandbox_mode: Option, +) -> Option { + if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() { + return Some(PermissionConfigSyntax::Legacy); + } + + let mut selection = None; + for layer in + config_layer_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) + { + let Ok(layer_selection) = layer.config.clone().try_into::() else { + continue; + }; + + if layer_selection.sandbox_mode.is_some() { + selection = Some(PermissionConfigSyntax::Legacy); + } + if layer_selection.default_permissions.is_some() { + selection = Some(PermissionConfigSyntax::Profiles); + } + } + + selection.or_else(|| { + if cfg.default_permissions.is_some() { + Some(PermissionConfigSyntax::Profiles) + } else if cfg.sandbox_mode.is_some() { + Some(PermissionConfigSyntax::Legacy) + } else { + None + } + }) +} + +fn add_additional_file_system_writes( + file_system_sandbox_policy: &mut FileSystemSandboxPolicy, + additional_writable_roots: &[AbsolutePathBuf], +) { + for path in additional_writable_roots { + let exists = file_system_sandbox_policy.entries.iter().any(|entry| { + matches!( + &entry.path, + codex_protocol::permissions::FileSystemPath::Path { path: existing } + if existing == path && entry.access == codex_protocol::permissions::FileSystemAccessMode::Write + ) + }); + if !exists { + file_system_sandbox_policy.entries.push( + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: path.clone() }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + ); + } + } +} + /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -1750,9 +1839,6 @@ impl Config { .clone(), None => ConfigProfile::default(), }; - let configured_network_proxy_config = - network_proxy_config_from_permissions(cfg.permissions.as_ref()); - let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, web_search_request: override_tools_web_search_request, @@ -1779,42 +1865,123 @@ impl Config { } } }); - let additional_writable_roots: Vec = additional_writable_roots + let mut additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd)) .collect::, _>>()?; let active_project = cfg .get_active_project(&resolved_cwd) .unwrap_or(ProjectConfig { trust_level: None }); + let permission_config_syntax = resolve_permission_config_syntax( + &config_layer_stack, + &cfg, + sandbox_mode, + config_profile.sandbox_mode, + ); + let has_permission_profiles = cfg + .permissions + .as_ref() + .is_some_and(|profiles| !profiles.is_empty()); + if has_permission_profiles + && !matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Legacy) + ) + && cfg.default_permissions.is_none() + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "config defines `[permissions]` profiles but does not set `default_permissions`", + )); + } let windows_sandbox_level = match windows_sandbox_mode { Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated, Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken, None => WindowsSandboxLevel::from_features(&features), }; - let mut sandbox_policy = cfg.derive_sandbox_policy( - sandbox_mode, - config_profile.sandbox_mode, - windows_sandbox_level, - &resolved_cwd, - Some(&constrained_sandbox_policy), - ); - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { - let memories_root = memory_root(&codex_home); - std::fs::create_dir_all(&memories_root)?; - let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; - if !writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - writable_roots.push(memories_root); + let memories_root = memory_root(&codex_home); + std::fs::create_dir_all(&memories_root)?; + let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; + if !additional_writable_roots + .iter() + .any(|existing| existing == &memories_root) + { + additional_writable_roots.push(memories_root); + } + + let profiles_are_active = matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ) || (permission_config_syntax.is_none() + && has_permission_profiles); + let ( + configured_network_proxy_config, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) = if profiles_are_active { + let permissions = cfg.permissions.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "default_permissions requires a named permissions profile", + ) + })?; + let profile = resolve_permission_profile(permissions, default_permissions)?; + let configured_network_proxy_config = + network_proxy_config_from_profile_network(profile.network.as_ref()); + let (mut file_system_sandbox_policy, network_sandbox_policy) = + compile_permission_profile(permissions, default_permissions)?; + let mut sandbox_policy = file_system_sandbox_policy + .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; + if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { + add_additional_file_system_writes( + &mut file_system_sandbox_policy, + &additional_writable_roots, + ); + sandbox_policy = file_system_sandbox_policy + .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; } - for path in additional_writable_roots { - if !writable_roots.iter().any(|existing| existing == &path) { - writable_roots.push(path); + ( + configured_network_proxy_config, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + } else { + let configured_network_proxy_config = NetworkProxyConfig::default(); + let mut sandbox_policy = cfg.derive_sandbox_policy( + sandbox_mode, + config_profile.sandbox_mode, + windows_sandbox_level, + &resolved_cwd, + Some(&constrained_sandbox_policy), + ); + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { + for path in &additional_writable_roots { + if !writable_roots.iter().any(|existing| existing == path) { + writable_roots.push(path.clone()); + } } } - } + let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); + ( + configured_network_proxy_config, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + }; + let approval_policy_was_explicit = approval_policy_override.is_some() + || config_profile.approval_policy.is_some() + || cfg.approval_policy.is_some(); let mut approval_policy = approval_policy_override .or(config_profile.approval_policy) .or(cfg.approval_policy) @@ -1827,7 +1994,9 @@ impl Config { AskForApproval::default() } }); - if let Err(err) = constrained_approval_policy.can_set(&approval_policy) { + if !approval_policy_was_explicit + && let Err(err) = constrained_approval_policy.can_set(&approval_policy) + { tracing::warn!( error = %err, "default approval policy is disallowed by requirements; falling back to required default" @@ -2072,6 +2241,7 @@ impl Config { .map(AbsolutePathBuf::to_path_buf) .or_else(|| resolve_sqlite_home_env(&resolved_cwd)) .unwrap_or_else(|| codex_home.to_path_buf()); + let original_sandbox_policy = sandbox_policy.clone(); apply_requirement_constrained_value( "approval_policy", @@ -2119,6 +2289,19 @@ impl Config { } else { network.enabled().then_some(network) }; + let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone(); + let effective_file_system_sandbox_policy = + if effective_sandbox_policy == original_sandbox_policy { + file_system_sandbox_policy + } else { + FileSystemSandboxPolicy::from(&effective_sandbox_policy) + }; + let effective_network_sandbox_policy = + if effective_sandbox_policy == original_sandbox_policy { + network_sandbox_policy + } else { + NetworkSandboxPolicy::from(&effective_sandbox_policy) + }; let config = Self { model, @@ -2133,6 +2316,8 @@ impl Config { permissions: Permissions { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, + file_system_sandbox_policy: effective_file_system_sandbox_policy, + network_sandbox_policy: effective_network_sandbox_policy, network, allow_login_shell, shell_environment_policy, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 15ecc065b1..b96039cec3 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,17 +1,60 @@ +use std::collections::BTreeMap; +use std::io; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] pub struct PermissionsToml { - /// Network proxy settings from `[permissions.network]`. - /// User config can enable the proxy; managed requirements may still constrain values. + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl PermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionProfileToml { + pub filesystem: Option, pub network: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct FilesystemPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FilesystemPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum FilesystemPermissionToml { + Access(FileSystemAccessMode), + Scoped(BTreeMap), +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct NetworkToml { @@ -91,13 +134,173 @@ impl NetworkToml { } } -pub(crate) fn network_proxy_config_from_permissions( - permissions: Option<&PermissionsToml>, +pub(crate) fn network_proxy_config_from_profile_network( + network: Option<&NetworkToml>, ) -> NetworkProxyConfig { - permissions - .and_then(|permissions| permissions.network.as_ref()) - .map_or_else( - NetworkProxyConfig::default, - NetworkToml::to_network_proxy_config, - ) + network.map_or_else( + NetworkProxyConfig::default, + NetworkToml::to_network_proxy_config, + ) +} + +pub(crate) fn resolve_permission_profile<'a>( + permissions: &'a PermissionsToml, + profile_name: &str, +) -> io::Result<&'a PermissionProfileToml> { + permissions.entries.get(profile_name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("default_permissions refers to undefined profile `{profile_name}`"), + ) + }) +} + +pub(crate) fn compile_permission_profile( + permissions: &PermissionsToml, + profile_name: &str, +) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { + let profile = resolve_permission_profile(permissions, profile_name)?; + + let filesystem = profile.filesystem.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "permissions profile `{profile_name}` must define a `[permissions.{profile_name}.filesystem]` table" + ), + ) + })?; + + if filesystem.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "permissions profile `{profile_name}` must define at least one filesystem entry" + ), + )); + } + + let mut entries = Vec::new(); + for (path, permission) in &filesystem.entries { + compile_filesystem_permission(path, permission, &mut entries)?; + } + + let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref()); + + Ok(( + FileSystemSandboxPolicy::restricted(entries), + network_sandbox_policy, + )) +} + +fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy { + let Some(network) = network else { + return NetworkSandboxPolicy::Restricted; + }; + + match network.enabled { + Some(true) => NetworkSandboxPolicy::Enabled, + _ => NetworkSandboxPolicy::Restricted, + } +} + +fn compile_filesystem_permission( + path: &str, + permission: &FilesystemPermissionToml, + entries: &mut Vec, +) -> io::Result<()> { + match permission { + FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry { + path: compile_filesystem_path(path)?, + access: *access, + }), + FilesystemPermissionToml::Scoped(scoped_entries) => { + for (subpath, access) in scoped_entries { + entries.push(FileSystemSandboxEntry { + path: compile_scoped_filesystem_path(path, subpath)?, + access: *access, + }); + } + } + } + Ok(()) +} + +fn compile_filesystem_path(path: &str) -> io::Result { + if let Some(special) = parse_special_path(path)? { + return Ok(FileSystemPath::Special { value: special }); + } + + let path = parse_absolute_path(path)?; + Ok(FileSystemPath::Path { path }) +} + +fn compile_scoped_filesystem_path(path: &str, subpath: &str) -> io::Result { + if subpath == "." { + return compile_filesystem_path(path); + } + + if let Some(special) = parse_special_path(path)? { + if !matches!(special, FileSystemSpecialPath::ProjectRoots { .. }) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("filesystem path `{path}` does not support nested entries"), + )); + } + return Ok(FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(parse_relative_subpath(subpath)?)), + }); + } + + let subpath = parse_relative_subpath(subpath)?; + let base = parse_absolute_path(path)?; + let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path())?; + Ok(FileSystemPath::Path { path }) +} + +fn parse_special_path(path: &str) -> io::Result> { + let special = match path { + ":root" => Some(FileSystemSpecialPath::Root), + ":minimal" => Some(FileSystemSpecialPath::Minimal), + ":project_roots" => Some(FileSystemSpecialPath::project_roots(None)), + ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), + _ if path.starts_with(':') => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unknown filesystem special path `{path}`"), + )); + } + _ => None, + }; + + Ok(special) +} + +fn parse_absolute_path(path: &str) -> io::Result { + let path_ref = Path::new(path); + if !path_ref.is_absolute() && path != "~" && !path.starts_with("~/") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("filesystem path `{path}` must be absolute, use `~/...`, or start with `:`"), + )); + } + AbsolutePathBuf::from_absolute_path(path_ref) +} + +fn parse_relative_subpath(subpath: &str) -> io::Result { + let path = Path::new(subpath); + if !subpath.is_empty() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Ok(path.to_path_buf()); + } + + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "filesystem subpath `{}` must be a descendant path without `.` or `..` components", + path.display() + ), + )) } diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 5ffd274be6..1c8244f70b 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::resolve_permission_profile; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; @@ -118,13 +119,7 @@ fn network_constraints_from_trusted_layers( } let parsed = network_tables_from_toml(&layer.config)?; - if let Some(network) = parsed.network { - apply_network_constraints(network, &mut constraints); - } - if let Some(network) = parsed - .permissions - .and_then(|permissions| permissions.network) - { + if let Some(network) = selected_network_from_tables(parsed)? { apply_network_constraints(network, &mut constraints); } } @@ -165,7 +160,7 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx #[derive(Debug, Clone, Default, Deserialize)] struct NetworkTablesToml { - network: Option, + default_permissions: Option, permissions: Option, } @@ -176,16 +171,24 @@ fn network_tables_from_toml(value: &toml::Value) -> Result { .context("failed to deserialize network tables from config") } -fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) { - if let Some(network) = parsed.network { - network.apply_to_network_proxy_config(config); - } - if let Some(network) = parsed +fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result> { + let Some(default_permissions) = parsed.default_permissions else { + return Ok(None); + }; + + let permissions = parsed .permissions - .and_then(|permissions| permissions.network) - { + .context("default_permissions requires a `[permissions]` table for network settings")?; + let profile = resolve_permission_profile(&permissions, &default_permissions) + .map_err(anyhow::Error::from)?; + Ok(profile.network.clone()) +} + +fn apply_network_tables(config: &mut NetworkProxyConfig, parsed: NetworkTablesToml) -> Result<()> { + if let Some(network) = selected_network_from_tables(parsed)? { network.apply_to_network_proxy_config(config); } + Ok(()) } fn config_from_layers( @@ -195,7 +198,7 @@ fn config_from_layers( let mut config = NetworkProxyConfig::default(); for layer in layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) { let parsed = network_tables_from_toml(&layer.config)?; - apply_network_tables(&mut config, parsed); + apply_network_tables(&mut config, parsed)?; } apply_exec_policy_network_rules(&mut config, exec_policy); Ok(config) @@ -310,17 +313,21 @@ mod tests { use pretty_assertions::assert_eq; #[test] - fn higher_precedence_network_table_beats_lower_permissions_network_table() { - let lower_permissions: toml::Value = toml::from_str( + fn higher_precedence_profile_network_beats_lower_profile_network() { + let lower_network: toml::Value = toml::from_str( r#" -[permissions.network] +default_permissions = "workspace" + +[permissions.workspace.network] allowed_domains = ["lower.example.com"] "#, ) .expect("lower layer should parse"); let higher_network: toml::Value = toml::from_str( r#" -[network] +default_permissions = "workspace" + +[permissions.workspace.network] allowed_domains = ["higher.example.com"] "#, ) @@ -329,12 +336,14 @@ allowed_domains = ["higher.example.com"] let mut config = NetworkProxyConfig::default(); apply_network_tables( &mut config, - network_tables_from_toml(&lower_permissions).expect("lower layer should deserialize"), - ); + 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, vec!["higher.example.com"]); } @@ -382,15 +391,18 @@ allowed_domains = ["higher.example.com"] fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { let config: toml::Value = toml::from_str( r#" -[network] +default_permissions = "workspace" + +[permissions.workspace.network] dangerously_allow_all_unix_sockets = true "#, ) - .expect("network table should parse"); - let network = network_tables_from_toml(&config) - .expect("network table should deserialize") - .network - .expect("network table should be present"); + .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); diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 5a94fa0fb1..5d7b0f5e04 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -537,6 +537,10 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() let permissions = Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + file_system_sandbox_policy: codex_protocol::permissions::FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 4349ed136f..abeb792afc 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2234,7 +2234,12 @@ async fn denying_network_policy_amendment_persists_policy_and_skips_future_netwo let home = Arc::new(TempDir::new()?); fs::write( home.path().join("config.toml"), - r#"[permissions.network] + r#"default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.network] enabled = true mode = "limited" allow_local_binding = true diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 15f8709442..6212b94264 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -13,10 +13,12 @@ It enforces an allow/deny policy and a "limited" mode intended for read-only net `codex-network-proxy` reads from Codex's merged `config.toml` (via `codex-core` config loading). -Example config: +Network settings live under the selected permissions profile. Example config: ```toml -[network] +default_permissions = "workspace" + +[permissions.workspace.network] enabled = true proxy_url = "http://127.0.0.1:3128" # SOCKS5 listener (enabled by default). diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 5841b1187e..afe78d37d1 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -12,6 +12,7 @@ pub mod models; pub mod num_format; pub mod openai_models; pub mod parse_command; +pub mod permissions; pub mod plan_tool; pub mod protocol; pub mod request_user_input; diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs new file mode 100644 index 0000000000..baf17e0ce2 --- /dev/null +++ b/codex-rs/protocol/src/permissions.rs @@ -0,0 +1,473 @@ +use std::collections::HashSet; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use strum_macros::Display; +use ts_rs::TS; + +use crate::protocol::NetworkAccess; +use crate::protocol::ReadOnlyAccess; +use crate::protocol::SandboxPolicy; + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum NetworkSandboxPolicy { + #[default] + Restricted, + Enabled, +} + +impl NetworkSandboxPolicy { + pub fn is_enabled(self) -> bool { + matches!(self, NetworkSandboxPolicy::Enabled) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum FileSystemAccessMode { + None, + Read, + Write, +} + +impl FileSystemAccessMode { + pub fn can_read(self) -> bool { + !matches!(self, FileSystemAccessMode::None) + } + + pub fn can_write(self) -> bool { + matches!(self, FileSystemAccessMode::Write) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[ts(tag = "kind")] +pub enum FileSystemSpecialPath { + Root, + Minimal, + CurrentWorkingDirectory, + ProjectRoots { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + subpath: Option, + }, + Tmpdir, + SlashTmp, +} + +impl FileSystemSpecialPath { + pub fn project_roots(subpath: Option) -> Self { + Self::ProjectRoots { subpath } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct FileSystemSandboxEntry { + pub path: FileSystemPath, + pub access: FileSystemAccessMode, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum FileSystemSandboxKind { + #[default] + Restricted, + Unrestricted, + ExternalSandbox, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct FileSystemSandboxPolicy { + pub kind: FileSystemSandboxKind, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +pub enum FileSystemPath { + Path { path: AbsolutePathBuf }, + Special { value: FileSystemSpecialPath }, +} + +impl Default for FileSystemSandboxPolicy { + fn default() -> Self { + Self { + kind: FileSystemSandboxKind::Restricted, + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }], + } + } +} + +impl FileSystemSandboxPolicy { + pub fn unrestricted() -> Self { + Self { + kind: FileSystemSandboxKind::Unrestricted, + entries: Vec::new(), + } + } + + pub fn external_sandbox() -> Self { + Self { + kind: FileSystemSandboxKind::ExternalSandbox, + entries: Vec::new(), + } + } + + pub fn restricted(entries: Vec) -> Self { + Self { + kind: FileSystemSandboxKind::Restricted, + entries, + } + } + + pub fn to_legacy_sandbox_policy( + &self, + network_policy: NetworkSandboxPolicy, + cwd: &Path, + ) -> io::Result { + Ok(match self.kind { + FileSystemSandboxKind::ExternalSandbox => SandboxPolicy::ExternalSandbox { + network_access: if network_policy.is_enabled() { + NetworkAccess::Enabled + } else { + NetworkAccess::Restricted + }, + }, + FileSystemSandboxKind::Unrestricted => { + if network_policy.is_enabled() { + SandboxPolicy::DangerFullAccess + } else { + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + } + } + } + FileSystemSandboxKind::Restricted => { + let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); + let mut include_platform_defaults = false; + let mut has_full_disk_read_access = false; + let mut has_full_disk_write_access = false; + let mut workspace_root_writable = false; + let mut writable_roots = Vec::new(); + let mut readable_roots = Vec::new(); + let mut tmpdir_writable = false; + let mut slash_tmp_writable = false; + + for entry in &self.entries { + match &entry.path { + FileSystemPath::Path { path } => { + if entry.access.can_write() { + if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) { + workspace_root_writable = true; + } else { + writable_roots.push(path.clone()); + } + } else if entry.access.can_read() { + readable_roots.push(path.clone()); + } + } + FileSystemPath::Special { value } => match value { + FileSystemSpecialPath::Root => match entry.access { + FileSystemAccessMode::None => {} + FileSystemAccessMode::Read => has_full_disk_read_access = true, + FileSystemAccessMode::Write => { + has_full_disk_read_access = true; + has_full_disk_write_access = true; + } + }, + FileSystemSpecialPath::Minimal => { + if entry.access.can_read() { + include_platform_defaults = true; + } + } + FileSystemSpecialPath::CurrentWorkingDirectory => { + if entry.access.can_write() { + workspace_root_writable = true; + } else if entry.access.can_read() + && let Some(path) = resolve_file_system_special_path( + value, + cwd_absolute.as_ref(), + ) + { + readable_roots.push(path); + } + } + FileSystemSpecialPath::ProjectRoots { subpath } => { + if subpath.is_none() && entry.access.can_write() { + workspace_root_writable = true; + } else if let Some(path) = + resolve_file_system_special_path(value, cwd_absolute.as_ref()) + { + if entry.access.can_write() { + writable_roots.push(path); + } else if entry.access.can_read() { + readable_roots.push(path); + } + } + } + FileSystemSpecialPath::Tmpdir => { + if entry.access.can_write() { + tmpdir_writable = true; + } else if entry.access.can_read() + && let Some(path) = resolve_file_system_special_path( + value, + cwd_absolute.as_ref(), + ) + { + readable_roots.push(path); + } + } + FileSystemSpecialPath::SlashTmp => { + if entry.access.can_write() { + slash_tmp_writable = true; + } else if entry.access.can_read() + && let Some(path) = resolve_file_system_special_path( + value, + cwd_absolute.as_ref(), + ) + { + readable_roots.push(path); + } + } + }, + } + } + + if has_full_disk_write_access { + return Ok(if network_policy.is_enabled() { + SandboxPolicy::DangerFullAccess + } else { + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + } + }); + } + + let read_only_access = if has_full_disk_read_access { + ReadOnlyAccess::FullAccess + } else { + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots: dedup_absolute_paths(readable_roots), + } + }; + + if workspace_root_writable { + SandboxPolicy::WorkspaceWrite { + writable_roots: dedup_absolute_paths(writable_roots), + read_only_access, + network_access: network_policy.is_enabled(), + exclude_tmpdir_env_var: !tmpdir_writable, + exclude_slash_tmp: !slash_tmp_writable, + } + } else if !writable_roots.is_empty() || tmpdir_writable || slash_tmp_writable { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly", + )); + } else { + SandboxPolicy::ReadOnly { + access: read_only_access, + network_access: network_policy.is_enabled(), + } + } + } + }) + } +} + +impl From<&SandboxPolicy> for NetworkSandboxPolicy { + fn from(value: &SandboxPolicy) -> Self { + if value.has_full_network_access() { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + } + } +} + +impl From<&SandboxPolicy> for FileSystemSandboxPolicy { + fn from(value: &SandboxPolicy) -> Self { + match value { + SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(), + SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(), + SandboxPolicy::ReadOnly { access, .. } => { + let mut entries = Vec::new(); + match access { + ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }), + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Read, + }); + if *include_platform_defaults { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }); + } + entries.extend(readable_roots.iter().cloned().map(|path| { + FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + } + })); + } + } + FileSystemSandboxPolicy::restricted(entries) + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + read_only_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + .. + } => { + let mut entries = Vec::new(); + match read_only_access { + ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }), + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + if *include_platform_defaults { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }); + } + entries.extend(readable_roots.iter().cloned().map(|path| { + FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + } + })); + } + } + + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }); + if !exclude_slash_tmp { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + }, + access: FileSystemAccessMode::Write, + }); + } + if !exclude_tmpdir_env_var { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Write, + }); + } + entries.extend( + writable_roots + .iter() + .cloned() + .map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Write, + }), + ); + FileSystemSandboxPolicy::restricted(entries) + } + } + } +} + +fn resolve_file_system_special_path( + value: &FileSystemSpecialPath, + cwd: Option<&AbsolutePathBuf>, +) -> Option { + match value { + FileSystemSpecialPath::Root | FileSystemSpecialPath::Minimal => None, + FileSystemSpecialPath::CurrentWorkingDirectory => { + let cwd = cwd?; + Some(cwd.clone()) + } + FileSystemSpecialPath::ProjectRoots { subpath } => { + let cwd = cwd?; + match subpath.as_ref() { + Some(subpath) => { + AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()).ok() + } + None => Some(cwd.clone()), + } + } + FileSystemSpecialPath::Tmpdir => { + let tmpdir = std::env::var_os("TMPDIR")?; + if tmpdir.is_empty() { + None + } else { + let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?; + Some(tmpdir) + } + } + FileSystemSpecialPath::SlashTmp => { + #[allow(clippy::expect_used)] + let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute"); + if !slash_tmp.as_path().is_dir() { + return None; + } + Some(slash_tmp) + } + } +} + +fn dedup_absolute_paths(paths: Vec) -> Vec { + let mut deduped = Vec::with_capacity(paths.len()); + let mut seen = HashSet::new(); + for path in paths { + if seen.insert(path.to_path_buf()) { + deduped.push(path); + } + } + deduped +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c5f94f9745..02776ff240 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3152,6 +3152,11 @@ mod tests { use crate::items::ImageGenerationItem; use crate::items::UserMessageItem; use crate::items::WebSearchItem; + use crate::permissions::FileSystemAccessMode; + use crate::permissions::FileSystemPath; + use crate::permissions::FileSystemSandboxEntry; + use crate::permissions::FileSystemSandboxPolicy; + use crate::permissions::NetworkSandboxPolicy; use anyhow::Result; use pretty_assertions::assert_eq; use serde_json::json; @@ -3236,6 +3241,36 @@ mod tests { } } + #[test] + fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() { + let cwd = if cfg!(windows) { + Path::new(r"C:\workspace") + } else { + Path::new("/tmp/workspace") + }; + let external_write_path = if cfg!(windows) { + AbsolutePathBuf::from_absolute_path(r"C:\temp").expect("absolute windows temp path") + } else { + AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute tmp path") + }; + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: external_write_path, + }, + access: FileSystemAccessMode::Write, + }]); + + let err = policy + .to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd) + .expect_err("non-workspace writes should be rejected"); + + assert!( + err.to_string() + .contains("filesystem writes outside the workspace root"), + "{err}" + ); + } + #[test] fn item_started_event_from_web_search_emits_begin_event() { let event = ItemStartedEvent { From a11c59f63426d7d763c5d9f7bdda932ba682e0d4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 6 Mar 2026 16:00:30 -0800 Subject: [PATCH 03/13] Add realtime startup context override (#13796) - add experimental_realtime_ws_startup_context to override or disable realtime websocket startup context - preserve generated startup context when unset and cover the new override paths in tests --- codex-rs/core/config.schema.json | 4 + codex-rs/core/src/config/config_tests.rs | 32 +++++ codex-rs/core/src/config/mod.rs | 9 ++ codex-rs/core/src/realtime_conversation.rs | 20 +-- .../core/tests/suite/realtime_conversation.rs | 117 ++++++++++++++++++ 5 files changed, 175 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 34239c0c9d..2edde6eb98 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1717,6 +1717,10 @@ "description": "Experimental / do not use. Selects the realtime websocket model/snapshot used for the `Op::RealtimeConversation` connection.", "type": "string" }, + "experimental_realtime_ws_startup_context": { + "description": "Experimental / do not use. Replaces the synthesized realtime startup context appended to websocket session instructions. An empty string disables startup context injection entirely.", + "type": "string" + }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e70f7c2b20..87f345b22f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3091,6 +3091,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, + experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -3224,6 +3225,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, + experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -3355,6 +3357,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, + experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -3472,6 +3475,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, + experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4432,6 +4436,34 @@ experimental_realtime_ws_backend_prompt = "prompt from config" Ok(()) } +#[test] +fn experimental_realtime_ws_startup_context_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_startup_context = "startup context from config" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_startup_context.as_deref(), + Some("startup context from config") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_realtime_ws_startup_context.as_deref(), + Some("startup context from config") + ); + Ok(()) +} + #[test] fn experimental_realtime_ws_model_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f4a3063e9c..3a250c09e5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -464,6 +464,10 @@ pub struct Config { /// websocket transport instructions (the `Op::RealtimeConversation` /// `/ws` session.update instructions) without changing normal prompts. pub experimental_realtime_ws_backend_prompt: Option, + /// Experimental / do not use. Replaces the synthesized realtime startup + /// context appended to websocket session instructions. An empty string + /// disables startup context injection entirely. + pub experimental_realtime_ws_startup_context: Option, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -1228,6 +1232,10 @@ pub struct ConfigToml { /// websocket transport instructions (the `Op::RealtimeConversation` /// `/ws` session.update instructions) without changing normal prompts. pub experimental_realtime_ws_backend_prompt: Option, + /// Experimental / do not use. Replaces the synthesized realtime startup + /// context appended to websocket session instructions. An empty string + /// disables startup context injection entirely. + pub experimental_realtime_ws_startup_context: Option, pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. @@ -2405,6 +2413,7 @@ impl Config { experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, experimental_realtime_ws_model: cfg.experimental_realtime_ws_model, experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, + experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 4d8d6127d5..8d69b6b9ef 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -284,13 +284,19 @@ pub(crate) async fn handle_start( .experimental_realtime_ws_backend_prompt .clone() .unwrap_or(params.prompt); - let prompt = - match build_realtime_startup_context(sess.as_ref(), REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET) - .await - { - Some(context) => format!("{prompt}\n\n{context}"), - None => prompt, - }; + let startup_context = match config.experimental_realtime_ws_startup_context.clone() { + Some(startup_context) => startup_context, + None => { + build_realtime_startup_context(sess.as_ref(), REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET) + .await + .unwrap_or_default() + } + }; + let prompt = if startup_context.is_empty() { + prompt + } else { + format!("{prompt}\n\n{startup_context}") + }; let model = config.experimental_realtime_ws_model.clone(); let requested_session_id = params diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 71976e00c2..3a9aa20455 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -628,6 +628,123 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_uses_experimental_realtime_ws_startup_context_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![ + vec![], + vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_custom_context", "instructions": "prompt from config" } + })]], + ]) + .await; + + let mut builder = test_codex().with_config(|config| { + config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); + config.experimental_realtime_ws_startup_context = + Some("custom startup context".to_string()); + }); + let test = builder.build_with_websocket_server(&server).await?; + seed_recent_thread( + &test, + "Recent work: cleaned up startup flows and reviewed websocket routing.", + "Investigate realtime startup context", + "custom-context", + ) + .await?; + fs::create_dir_all(test.workspace_path("docs"))?; + fs::write(test.workspace_path("README.md"), "workspace marker")?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "prompt from op".to_string(), + session_id: None, + })) + .await?; + + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_custom_context" => Some(Ok(())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + + let startup_context_request = server.wait_for_request(1, 0).await; + let instructions = websocket_request_instructions(&startup_context_request) + .expect("custom startup context request should contain instructions"); + + assert_eq!(instructions, "prompt from config\n\ncustom startup context"); + assert!(!instructions.contains(STARTUP_CONTEXT_HEADER)); + assert!(!instructions.contains("## Machine / Workspace Map")); + + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_disables_realtime_startup_context_with_empty_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![ + vec![], + vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_no_context", "instructions": "prompt from config" } + })]], + ]) + .await; + + let mut builder = test_codex().with_config(|config| { + config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); + config.experimental_realtime_ws_startup_context = Some(String::new()); + }); + let test = builder.build_with_websocket_server(&server).await?; + seed_recent_thread( + &test, + "Recent work: cleaned up startup flows and reviewed websocket routing.", + "Investigate realtime startup context", + "no-context", + ) + .await?; + fs::create_dir_all(test.workspace_path("docs"))?; + fs::write(test.workspace_path("README.md"), "workspace marker")?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "prompt from op".to_string(), + session_id: None, + })) + .await?; + + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_no_context" => Some(Ok(())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + + let startup_context_request = server.wait_for_request(1, 0).await; + let instructions = websocket_request_instructions(&startup_context_request) + .expect("startup context disable request should contain instructions"); + + assert_eq!(instructions, "prompt from config"); + assert!(!instructions.contains(STARTUP_CONTEXT_HEADER)); + assert!(!instructions.contains("## Machine / Workspace Map")); + + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_start_injects_startup_context_from_thread_history() -> Result<()> { skip_if_no_network!(Ok(())); From 3794363cacb13f3df3587de524b1e53fc6803d3f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 6 Mar 2026 16:21:14 -0800 Subject: [PATCH 04/13] fix: include libcap-dev dependency when creating a devcontainer for building Codex (#13814) I mainly use the devcontainer to be able to run `cargo clippy --tests` locally for Linux. We still need to make it possible to run clippy from Bazel so I don't need to do this! --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d020dfe734..8c3a859779 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update && \ RUN apt-get update && \ apt-get install -y --no-install-recommends \ build-essential curl git ca-certificates \ - pkg-config clang musl-tools libssl-dev just && \ + pkg-config libcap-dev clang musl-tools libssl-dev just && \ rm -rf /var/lib/apt/lists/* # Ubuntu 24.04 ships with user 'ubuntu' already created with UID 1000. From 289ed549cf4102414fab679d77481d3440368800 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 6 Mar 2026 16:23:30 -0800 Subject: [PATCH 05/13] chore(otel): rename OtelManager to SessionTelemetry (#13808) ## Summary This is a purely mechanical refactor of `OtelManager` -> `SessionTelemetry` to better convey what the struct is doing. No behavior change. ## Why `OtelManager` ended up sounding much broader than what this type actually does. It doesn't manage OTEL globally; it's the session-scoped telemetry surface for emitting log/trace events and recording metrics with consistent session metadata (`app_version`, `model`, `slug`, `originator`, etc.). `SessionTelemetry` is a more accurate name, and updating the call sites makes that boundary a lot easier to follow. ## Validation - `just fmt` - `cargo test -p codex-otel` - `cargo test -p codex-core` --- codex-rs/core/src/client.rs | 100 +++++++++--------- codex-rs/core/src/codex.rs | 62 +++++------ codex-rs/core/src/codex_tests.rs | 18 ++-- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/compact_remote.rs | 2 +- codex-rs/core/src/features.rs | 4 +- codex-rs/core/src/mcp_tool_call.rs | 6 +- codex-rs/core/src/memories/phase1.rs | 34 +++--- codex-rs/core/src/memories/phase2.rs | 20 ++-- codex-rs/core/src/memories/usage.rs | 2 +- codex-rs/core/src/memory_trace.rs | 6 +- codex-rs/core/src/rollout/metadata.rs | 6 +- codex-rs/core/src/shell_snapshot.rs | 16 +-- codex-rs/core/src/skills/injection.rs | 10 +- codex-rs/core/src/skills/invocation_utils.rs | 2 +- codex-rs/core/src/state/service.rs | 4 +- codex-rs/core/src/state_db.rs | 12 ++- codex-rs/core/src/stream_events_utils.rs | 2 +- codex-rs/core/src/tasks/compact.rs | 4 +- codex-rs/core/src/tasks/mod.rs | 14 +-- codex-rs/core/src/tasks/regular.rs | 2 +- codex-rs/core/src/tasks/review.rs | 2 +- codex-rs/core/src/tasks/undo.rs | 2 +- codex-rs/core/src/tasks/user_shell.rs | 2 +- .../core/src/tools/handlers/multi_agents.rs | 4 +- codex-rs/core/src/tools/orchestrator.rs | 2 +- codex-rs/core/src/tools/registry.rs | 2 +- codex-rs/core/src/tools/sandboxing.rs | 2 +- codex-rs/core/src/turn_timing.rs | 4 +- codex-rs/core/tests/responses_headers.rs | 14 +-- codex-rs/core/tests/suite/client.rs | 6 +- .../core/tests/suite/client_websockets.rs | 40 +++---- codex-rs/otel/README.md | 14 +-- codex-rs/otel/src/events/mod.rs | 2 +- .../{otel_manager.rs => session_telemetry.rs} | 16 +-- codex-rs/otel/src/lib.rs | 4 +- codex-rs/otel/tests/suite/manager_metrics.rs | 12 +-- .../tests/suite/otel_export_routing_policy.rs | 6 +- codex-rs/otel/tests/suite/runtime_summary.rs | 4 +- codex-rs/otel/tests/suite/snapshot.rs | 4 +- codex-rs/state/src/runtime.rs | 4 +- codex-rs/state/src/runtime/threads.rs | 2 +- codex-rs/tui/src/app.rs | 64 ++++++----- codex-rs/tui/src/chatwidget.rs | 46 ++++---- codex-rs/tui/src/chatwidget/tests.rs | 22 ++-- 45 files changed, 318 insertions(+), 290 deletions(-) rename codex-rs/otel/src/events/{otel_manager.rs => session_telemetry.rs} (98%) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index ce354d9b04..642a6dec5a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -57,7 +57,7 @@ use codex_api::common::ResponsesWsRequest; use codex_api::create_text_param_for_request; use codex_api::error::ApiError; use codex_api::requests::responses::Compression; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -281,14 +281,14 @@ impl ModelClient { &self, prompt: &Prompt, model_info: &ModelInfo, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, ) -> Result> { if prompt.input.is_empty() { return Ok(Vec::new()); } let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); - let request_telemetry = Self::build_request_telemetry(otel_manager); + let request_telemetry = Self::build_request_telemetry(session_telemetry); let client = ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) .with_telemetry(Some(request_telemetry)); @@ -321,7 +321,7 @@ impl ModelClient { raw_memories: Vec, model_info: &ModelInfo, effort: Option, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, ) -> Result> { if raw_memories.is_empty() { return Ok(Vec::new()); @@ -329,7 +329,7 @@ impl ModelClient { let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); - let request_telemetry = Self::build_request_telemetry(otel_manager); + let request_telemetry = Self::build_request_telemetry(session_telemetry); let client = ApiMemoriesClient::new(transport, client_setup.api_provider, client_setup.api_auth) .with_telemetry(Some(request_telemetry)); @@ -369,8 +369,8 @@ impl ModelClient { } /// Builds request telemetry for unary API calls (e.g., Compact endpoint). - fn build_request_telemetry(otel_manager: &OtelManager) -> Arc { - let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone())); + fn build_request_telemetry(session_telemetry: &SessionTelemetry) -> Arc { + let telemetry = Arc::new(ApiTelemetry::new(session_telemetry.clone())); let request_telemetry: Arc = telemetry; request_telemetry } @@ -419,14 +419,14 @@ impl ModelClient { /// behavior remains consistent across both flows. async fn connect_websocket( &self, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, turn_state: Option>>, turn_metadata_header: Option<&str>, ) -> std::result::Result { let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); - let websocket_telemetry = ModelClientSession::build_websocket_telemetry(otel_manager); + let websocket_telemetry = ModelClientSession::build_websocket_telemetry(session_telemetry); ApiWebSocketResponsesClient::new(api_provider, api_auth) .connect( headers, @@ -660,7 +660,7 @@ impl ModelClientSession { /// This performs only connection setup; it never sends prompt payloads. pub async fn preconnect_websocket( &mut self, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, model_info: &ModelInfo, ) -> std::result::Result<(), ApiError> { if !self.client.responses_websocket_enabled(model_info) { @@ -679,7 +679,7 @@ impl ModelClientSession { let connection = self .client .connect_websocket( - otel_manager, + session_telemetry, client_setup.api_provider, client_setup.api_auth, Some(Arc::clone(&self.turn_state)), @@ -692,7 +692,7 @@ impl ModelClientSession { /// Returns a websocket connection for this turn. async fn websocket_connection( &mut self, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, turn_metadata_header: Option<&str>, @@ -713,7 +713,7 @@ impl ModelClientSession { let new_conn = self .client .connect_websocket( - otel_manager, + session_telemetry, api_provider, api_auth, Some(turn_state), @@ -751,7 +751,7 @@ impl ModelClientSession { &self, prompt: &Prompt, model_info: &ModelInfo, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, service_tier: Option, @@ -764,7 +764,7 @@ impl ModelClientSession { self.client.state.provider.stream_idle_timeout(), ) .map_err(map_api_error)?; - let (stream, _last_request_rx) = map_response_stream(stream, otel_manager.clone()); + let (stream, _last_request_rx) = map_response_stream(stream, session_telemetry.clone()); return Ok(stream); } @@ -775,7 +775,8 @@ impl ModelClientSession { loop { let client_setup = self.client.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); - let (request_telemetry, sse_telemetry) = Self::build_streaming_telemetry(otel_manager); + let (request_telemetry, sse_telemetry) = + Self::build_streaming_telemetry(session_telemetry); let compression = self.responses_request_compression(client_setup.auth.as_ref()); let options = self.build_responses_options(turn_metadata_header, compression); @@ -797,7 +798,7 @@ impl ModelClientSession { match stream_result { Ok(stream) => { - let (stream, _) = map_response_stream(stream, otel_manager.clone()); + let (stream, _) = map_response_stream(stream, session_telemetry.clone()); return Ok(stream); } Err(ApiError::Transport( @@ -817,7 +818,7 @@ impl ModelClientSession { &mut self, prompt: &Prompt, model_info: &ModelInfo, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, service_tier: Option, @@ -852,7 +853,7 @@ impl ModelClientSession { match self .websocket_connection( - otel_manager, + session_telemetry, client_setup.api_provider, client_setup.api_auth, turn_metadata_header, @@ -890,7 +891,7 @@ impl ModelClientSession { .await .map_err(map_api_error)?; let (stream, last_request_rx) = - map_response_stream(stream_result, otel_manager.clone()); + map_response_stream(stream_result, session_telemetry.clone()); self.websocket_session.last_response_rx = Some(last_request_rx); return Ok(WebsocketStreamOutcome::Stream(stream)); } @@ -898,17 +899,19 @@ impl ModelClientSession { /// Builds request and SSE telemetry for streaming API calls. fn build_streaming_telemetry( - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, ) -> (Arc, Arc) { - let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone())); + let telemetry = Arc::new(ApiTelemetry::new(session_telemetry.clone())); let request_telemetry: Arc = telemetry.clone(); let sse_telemetry: Arc = telemetry; (request_telemetry, sse_telemetry) } /// Builds telemetry for the Responses API WebSocket transport. - fn build_websocket_telemetry(otel_manager: &OtelManager) -> Arc { - let telemetry = Arc::new(ApiTelemetry::new(otel_manager.clone())); + fn build_websocket_telemetry( + session_telemetry: &SessionTelemetry, + ) -> Arc { + let telemetry = Arc::new(ApiTelemetry::new(session_telemetry.clone())); let websocket_telemetry: Arc = telemetry; websocket_telemetry } @@ -918,7 +921,7 @@ impl ModelClientSession { &mut self, prompt: &Prompt, model_info: &ModelInfo, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, service_tier: Option, @@ -935,7 +938,7 @@ impl ModelClientSession { .stream_responses_websocket( prompt, model_info, - otel_manager, + session_telemetry, effort, summary, service_tier, @@ -956,7 +959,7 @@ impl ModelClientSession { Ok(()) } Ok(WebsocketStreamOutcome::FallbackToHttp) => { - self.try_switch_fallback_transport(otel_manager, model_info); + self.try_switch_fallback_transport(session_telemetry, model_info); Ok(()) } Err(err) => Err(err), @@ -974,7 +977,7 @@ impl ModelClientSession { &mut self, prompt: &Prompt, model_info: &ModelInfo, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, service_tier: Option, @@ -988,7 +991,7 @@ impl ModelClientSession { .stream_responses_websocket( prompt, model_info, - otel_manager, + session_telemetry, effort, summary, service_tier, @@ -999,7 +1002,7 @@ impl ModelClientSession { { WebsocketStreamOutcome::Stream(stream) => return Ok(stream), WebsocketStreamOutcome::FallbackToHttp => { - self.try_switch_fallback_transport(otel_manager, model_info); + self.try_switch_fallback_transport(session_telemetry, model_info); } } } @@ -1007,7 +1010,7 @@ impl ModelClientSession { self.stream_responses_api( prompt, model_info, - otel_manager, + session_telemetry, effort, summary, service_tier, @@ -1026,14 +1029,14 @@ impl ModelClientSession { /// Returns `true` if this call activated fallback, or `false` if fallback was already active. pub(crate) fn try_switch_fallback_transport( &mut self, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, model_info: &ModelInfo, ) -> bool { let websocket_enabled = self.client.responses_websocket_enabled(model_info); let activated = self.activate_http_fallback(websocket_enabled); if activated { warn!("falling back to HTTP"); - otel_manager.counter( + session_telemetry.counter( "codex.transport.fallback_to_http", 1, &[("from_wire_api", "responses_websocket")], @@ -1096,7 +1099,7 @@ fn build_responses_headers( fn map_response_stream( api_stream: S, - otel_manager: OtelManager, + session_telemetry: SessionTelemetry, ) -> (ResponseStream, oneshot::Receiver) where S: futures::Stream> @@ -1129,7 +1132,7 @@ where token_usage, }) => { if let Some(usage) = &token_usage { - otel_manager.sse_event_completed( + session_telemetry.sse_event_completed( usage.input_tokens, usage.output_tokens, Some(usage.cached_input_tokens), @@ -1162,7 +1165,7 @@ where Err(err) => { let mapped = map_api_error(err); if !logged_error { - otel_manager.see_event_completed_failed(&mapped); + session_telemetry.see_event_completed_failed(&mapped); logged_error = true; } if tx_event.send(Err(mapped)).await.is_err() { @@ -1198,12 +1201,12 @@ async fn handle_unauthorized( } struct ApiTelemetry { - otel_manager: OtelManager, + session_telemetry: SessionTelemetry, } impl ApiTelemetry { - fn new(otel_manager: OtelManager) -> Self { - Self { otel_manager } + fn new(session_telemetry: SessionTelemetry) -> Self { + Self { session_telemetry } } } @@ -1216,7 +1219,7 @@ impl RequestTelemetry for ApiTelemetry { duration: Duration, ) { let error_message = error.map(std::string::ToString::to_string); - self.otel_manager.record_api_request( + self.session_telemetry.record_api_request( attempt, status.map(|s| s.as_u16()), error_message.as_deref(), @@ -1234,14 +1237,14 @@ impl SseTelemetry for ApiTelemetry { >, duration: Duration, ) { - self.otel_manager.log_sse_event(result, duration); + self.session_telemetry.log_sse_event(result, duration); } } impl WebsocketTelemetry for ApiTelemetry { fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>) { let error_message = error.map(std::string::ToString::to_string); - self.otel_manager + self.session_telemetry .record_websocket_request(duration, error_message.as_deref()); } @@ -1250,14 +1253,15 @@ impl WebsocketTelemetry for ApiTelemetry { result: &std::result::Result>, ApiError>, duration: Duration, ) { - self.otel_manager.record_websocket_event(result, duration); + self.session_telemetry + .record_websocket_event(result, duration); } } #[cfg(test)] mod tests { use super::ModelClient; - use codex_otel::OtelManager; + use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::SessionSource; @@ -1313,8 +1317,8 @@ mod tests { .expect("deserialize test model info") } - fn test_otel_manager() -> OtelManager { - OtelManager::new( + fn test_session_telemetry() -> SessionTelemetry { + SessionTelemetry::new( ThreadId::new(), "gpt-test", "gpt-test", @@ -1344,10 +1348,10 @@ mod tests { async fn summarize_memories_returns_empty_for_empty_input() { let client = test_model_client(SessionSource::Cli); let model_info = test_model_info(); - let otel_manager = test_otel_manager(); + let session_telemetry = test_session_telemetry(); let output = client - .summarize_memories(Vec::new(), &model_info, None, &otel_manager) + .summarize_memories(Vec::new(), &model_info, None, &session_telemetry) .await .expect("empty summarize request should succeed"); assert_eq!(output.len(), 0); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index bd32dfe029..519b28217d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -297,7 +297,7 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::util::backoff; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_async_utils::OrCancelExt; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; @@ -664,7 +664,7 @@ pub(crate) struct TurnContext { pub(crate) config: Arc, pub(crate) auth_manager: Option>, pub(crate) model_info: ModelInfo, - pub(crate) otel_manager: OtelManager, + pub(crate) session_telemetry: SessionTelemetry, pub(crate) provider: ModelProviderInfo, pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, @@ -754,8 +754,8 @@ impl TurnContext { config: Arc::new(config), auth_manager: self.auth_manager.clone(), model_info: model_info.clone(), - otel_manager: self - .otel_manager + session_telemetry: self + .session_telemetry .clone() .with_model(model.as_str(), model_info.slug.as_str()), provider: self.provider.clone(), @@ -1089,7 +1089,7 @@ impl Session { #[allow(clippy::too_many_arguments)] fn make_turn_context( auth_manager: Option>, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, per_turn_config: Config, @@ -1103,14 +1103,14 @@ impl Session { let reasoning_summary = session_configuration .model_reasoning_summary .unwrap_or(model_info.default_reasoning_summary); - let otel_manager = otel_manager.clone().with_model( + let session_telemetry = session_telemetry.clone().with_model( session_configuration.collaboration_mode.model(), model_info.slug.as_str(), ); let session_source = session_configuration.session_source.clone(); let auth_manager_for_context = auth_manager; let provider_for_context = provider; - let otel_manager_for_context = otel_manager; + let session_telemetry_for_context = session_telemetry; let per_turn_config = Arc::new(per_turn_config); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -1140,7 +1140,7 @@ impl Session { config: per_turn_config.clone(), auth_manager: auth_manager_for_context, model_info: model_info.clone(), - otel_manager: otel_manager_for_context, + session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, reasoning_summary, @@ -1355,7 +1355,7 @@ impl Session { let originator = crate::default_client::originator().value; let terminal_type = terminal::user_agent(); let session_model = session_configuration.collaboration_mode.model().to_string(); - let mut otel_manager = OtelManager::new( + let mut session_telemetry = SessionTelemetry::new( conversation_id, session_model.as_str(), session_model.as_str(), @@ -1368,7 +1368,7 @@ impl Session { session_configuration.session_source.clone(), ); if let Some(service_name) = session_configuration.metrics_service_name.as_deref() { - otel_manager = otel_manager.with_metrics_service_name(service_name); + session_telemetry = session_telemetry.with_metrics_service_name(service_name); } let network_proxy_audit_metadata = NetworkProxyAuditMetadata { conversation_id: Some(conversation_id.to_string()), @@ -1381,8 +1381,8 @@ impl Session { model: Some(session_model.clone()), slug: Some(session_model), }; - config.features.emit_metrics(&otel_manager); - otel_manager.counter( + config.features.emit_metrics(&session_telemetry); + session_telemetry.counter( "codex.thread.started", 1, &[( @@ -1395,7 +1395,7 @@ impl Session { )], ); - otel_manager.conversation_starts( + session_telemetry.conversation_starts( config.model_provider.name.as_str(), session_configuration.collaboration_mode.reasoning_effort(), config @@ -1438,7 +1438,7 @@ impl Session { conversation_id, session_configuration.cwd.clone(), &mut default_shell, - otel_manager.clone(), + session_telemetry.clone(), ) } } else { @@ -1533,7 +1533,7 @@ impl Session { show_raw_agent_reasoning: config.show_raw_agent_reasoning, exec_policy, auth_manager: Arc::clone(&auth_manager), - otel_manager, + session_telemetry, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), execve_session_approvals: RwLock::new(HashMap::new()), @@ -2055,7 +2055,7 @@ impl Session { next_cwd.to_path_buf(), self.services.user_shell.as_ref().clone(), self.services.shell_snapshot_tx.clone(), - self.services.otel_manager.clone(), + self.services.session_telemetry.clone(), ); } @@ -2202,7 +2202,7 @@ impl Session { ); let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), - &self.services.otel_manager, + &self.services.session_telemetry, session_configuration.provider.clone(), &session_configuration, per_turn_config, @@ -3024,7 +3024,7 @@ impl Session { pub(crate) async fn record_model_warning(&self, message: impl Into, ctx: &TurnContext) { self.services - .otel_manager + .session_telemetry .counter("codex.model_warning", 1, &[]); let item = ResponseItem::Message { id: None, @@ -4178,7 +4178,7 @@ mod handlers { }; sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; - current_context.otel_manager.user_prompt(&items); + current_context.session_telemetry.user_prompt(&items); // Attempt to inject input into current task. if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await { @@ -4814,7 +4814,7 @@ mod handlers { .iter() .filter(|item| is_user_turn_boundary(item)) .count(); - sess.services.otel_manager.counter( + sess.services.session_telemetry.counter( "codex.conversation.turn.count", i64::try_from(turn_count).unwrap_or(0), &[], @@ -4933,13 +4933,13 @@ async fn spawn_review_thread( ); } - let otel_manager = parent_turn_context - .otel_manager + let session_telemetry = parent_turn_context + .session_telemetry .clone() .with_model(model.as_str(), review_model_info.slug.as_str()); let auth_manager_for_context = auth_manager.clone(); let provider_for_context = provider.clone(); - let otel_manager_for_context = otel_manager.clone(); + let session_telemetry_for_context = session_telemetry.clone(); let reasoning_effort = per_turn_config.model_reasoning_effort; let reasoning_summary = per_turn_config .model_reasoning_summary @@ -4965,7 +4965,7 @@ async fn spawn_review_thread( config: per_turn_config, auth_manager: auth_manager_for_context, model_info: model_info.clone(), - otel_manager: otel_manager_for_context, + session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, reasoning_summary, @@ -5194,7 +5194,7 @@ pub(crate) async fn run_turn( ) .await; - let otel_manager = turn_context.otel_manager.clone(); + let session_telemetry = turn_context.session_telemetry.clone(); let thread_id = sess.conversation_id.to_string(); let tracking = build_track_events_context( turn_context.model_info.slug.clone(), @@ -5206,7 +5206,7 @@ pub(crate) async fn run_turn( warnings: skill_warnings, } = build_skill_injections( &mentioned_skills, - Some(&otel_manager), + Some(&session_telemetry), &sess.services.analytics_events_client, tracking.clone(), ) @@ -5829,8 +5829,10 @@ async fn run_sampling_request( // Use the configured provider-specific stream retry budget. let max_retries = turn_context.provider.stream_max_retries(); if retries >= max_retries - && client_session - .try_switch_fallback_transport(&turn_context.otel_manager, &turn_context.model_info) + && client_session.try_switch_fallback_transport( + &turn_context.session_telemetry, + &turn_context.model_info, + ) { sess.send_event( &turn_context, @@ -6533,7 +6535,7 @@ async fn try_run_sampling_request( .stream( prompt, &turn_context.model_info, - &turn_context.otel_manager, + &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, turn_context.config.service_tier, @@ -6589,7 +6591,7 @@ async fn try_run_sampling_request( }; sess.services - .otel_manager + .session_telemetry .record_responses(&handle_responses, &event); record_turn_ttft_metric(&turn_context, &event).await; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index cb44d1cada..7fe6076f72 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1811,13 +1811,13 @@ async fn build_test_config(codex_home: &Path) -> Config { .expect("load default test config") } -fn otel_manager( +fn session_telemetry( conversation_id: ThreadId, config: &Config, model_info: &ModelInfo, session_source: SessionSource, -) -> OtelManager { - OtelManager::new( +) -> SessionTelemetry { + SessionTelemetry::new( conversation_id, ModelsManager::get_model_offline_for_tests(config.model.as_deref()).as_str(), model_info.slug.as_str(), @@ -2026,7 +2026,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_configuration.collaboration_mode.model(), &per_turn_config, ); - let otel_manager = otel_manager( + let session_telemetry = session_telemetry( conversation_id, config.as_ref(), &model_info, @@ -2068,7 +2068,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { show_raw_agent_reasoning: config.show_raw_agent_reasoning, exec_policy, auth_manager: auth_manager.clone(), - otel_manager: otel_manager.clone(), + session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), execve_session_approvals: RwLock::new(HashMap::new()), @@ -2100,7 +2100,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); let turn_context = Session::make_turn_context( Some(Arc::clone(&auth_manager)), - &otel_manager, + &session_telemetry, session_configuration.provider.clone(), &session_configuration, per_turn_config, @@ -2431,7 +2431,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( session_configuration.collaboration_mode.model(), &per_turn_config, ); - let otel_manager = otel_manager( + let session_telemetry = session_telemetry( conversation_id, config.as_ref(), &model_info, @@ -2473,7 +2473,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( show_raw_agent_reasoning: config.show_raw_agent_reasoning, exec_policy, auth_manager: Arc::clone(&auth_manager), - otel_manager: otel_manager.clone(), + session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), execve_session_approvals: RwLock::new(HashMap::new()), @@ -2505,7 +2505,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); let turn_context = Arc::new(Session::make_turn_context( Some(Arc::clone(&auth_manager)), - &otel_manager, + &session_telemetry, session_configuration.provider.clone(), &session_configuration, per_turn_config, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 6c7dbb1050..42e338443d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -400,7 +400,7 @@ async fn drain_to_completed( .stream( prompt, &turn_context.model_info, - &turn_context.otel_manager, + &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, turn_context.config.service_tier, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 40c520f659..473d91e549 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -107,7 +107,7 @@ async fn run_remote_compact_task_inner_impl( .compact_conversation_history( &prompt, &turn_context.model_info, - &turn_context.otel_manager, + &turn_context.session_telemetry, ) .or_else(|err| async { let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 7defc571ff..43c0b06b64 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -12,7 +12,7 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::WarningEvent; use codex_config::CONFIG_TOML_FILE; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -278,7 +278,7 @@ impl Features { self.legacy_usages.iter() } - pub fn emit_metrics(&self, otel: &OtelManager) { + pub fn emit_metrics(&self, otel: &SessionTelemetry) { for feature in FEATURES { if matches!(feature.stage, Stage::Removed) { continue; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 3900ea417a..3dea0961cd 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -107,7 +107,7 @@ pub(crate) async fn handle_mcp_tool_call( .await; let status = if result.is_ok() { "ok" } else { "error" }; turn_context - .otel_manager + .session_telemetry .counter("codex.mcp.call", 1, &[("status", status)]); return ResponseInputItem::McpToolCallOutput { call_id, result }; } @@ -188,7 +188,7 @@ pub(crate) async fn handle_mcp_tool_call( let status = if result.is_ok() { "ok" } else { "error" }; turn_context - .otel_manager + .session_telemetry .counter("codex.mcp.call", 1, &[("status", status)]); return ResponseInputItem::McpToolCallOutput { call_id, result }; @@ -229,7 +229,7 @@ pub(crate) async fn handle_mcp_tool_call( let status = if result.is_ok() { "ok" } else { "error" }; turn_context - .otel_manager + .session_telemetry .counter("codex.mcp.call", 1, &[("status", status)]); ResponseInputItem::McpToolCallOutput { call_id, result } diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index b860f84ee3..ad4f29a0d2 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -12,7 +12,7 @@ use crate::memories::prompts::build_stage_one_input_message; use crate::rollout::INTERACTIVE_SESSION_SOURCES; use crate::rollout::policy::should_persist_response_item_for_memories; use codex_api::ResponseEvent; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::BaseInstructions; @@ -35,7 +35,7 @@ use tracing::warn; #[derive(Clone, Debug)] pub(in crate::memories) struct RequestContext { pub(in crate::memories) model_info: ModelInfo, - pub(in crate::memories) otel_manager: OtelManager, + pub(in crate::memories) session_telemetry: SessionTelemetry, pub(in crate::memories) reasoning_effort: Option, pub(in crate::memories) reasoning_summary: ReasoningSummaryConfig, pub(in crate::memories) service_tier: Option, @@ -85,7 +85,7 @@ struct StageOneOutput { pub(in crate::memories) async fn run(session: &Arc, config: &Config) { let _phase_one_e2e_timer = session .services - .otel_manager + .session_telemetry .start_timer(metrics::MEMORY_PHASE_ONE_E2E_MS, &[]) .ok(); @@ -94,7 +94,7 @@ pub(in crate::memories) async fn run(session: &Arc, config: &Config) { return; }; if claimed_candidates.is_empty() { - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_ONE_JOBS, 1, &[("status", "skipped_no_candidates")], @@ -168,7 +168,7 @@ impl RequestContext { Self { model_info, turn_metadata_header, - otel_manager: turn_context.otel_manager.clone(), + session_telemetry: turn_context.session_telemetry.clone(), reasoning_effort: Some(phase_one::REASONING_EFFORT), reasoning_summary: turn_context.reasoning_summary, service_tier: turn_context.config.service_tier, @@ -208,7 +208,7 @@ async fn claim_startup_jobs( Ok(claims) => Some(claims), Err(err) => { warn!("state db claim_stage1_jobs_for_startup failed during memories startup: {err}"); - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_ONE_JOBS, 1, &[("status", "failed_claim")], @@ -347,7 +347,7 @@ mod job { .stream( &prompt, &stage_one_context.model_info, - &stage_one_context.otel_manager, + &stage_one_context.session_telemetry, stage_one_context.reasoning_effort, stage_one_context.reasoning_summary, stage_one_context.service_tier, @@ -516,60 +516,60 @@ fn aggregate_stats(outcomes: Vec) -> Stats { fn emit_metrics(session: &Session, counts: &Stats) { if counts.claimed > 0 { - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_ONE_JOBS, counts.claimed as i64, &[("status", "claimed")], ); } if counts.succeeded_with_output > 0 { - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_ONE_JOBS, counts.succeeded_with_output as i64, &[("status", "succeeded")], ); - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_ONE_OUTPUT, counts.succeeded_with_output as i64, &[], ); } if counts.succeeded_no_output > 0 { - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_ONE_JOBS, counts.succeeded_no_output as i64, &[("status", "succeeded_no_output")], ); } if counts.failed > 0 { - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_ONE_JOBS, counts.failed as i64, &[("status", "failed")], ); } if let Some(token_usage) = counts.total_token_usage.as_ref() { - session.services.otel_manager.histogram( + session.services.session_telemetry.histogram( metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, token_usage.total_tokens.max(0), &[("token_type", "total")], ); - session.services.otel_manager.histogram( + session.services.session_telemetry.histogram( metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, token_usage.input_tokens.max(0), &[("token_type", "input")], ); - session.services.otel_manager.histogram( + session.services.session_telemetry.histogram( metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, token_usage.cached_input(), &[("token_type", "cached_input")], ); - session.services.otel_manager.histogram( + session.services.session_telemetry.histogram( metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, token_usage.output_tokens.max(0), &[("token_type", "output")], ); - session.services.otel_manager.histogram( + session.services.session_telemetry.histogram( metrics::MEMORY_PHASE_ONE_TOKEN_USAGE, token_usage.reasoning_output_tokens.max(0), &[("token_type", "reasoning_output")], diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index c8575035e5..1a31bb3358 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -43,7 +43,7 @@ struct Counters { pub(super) async fn run(session: &Arc, config: Arc) { let phase_two_e2e_timer = session .services - .otel_manager + .session_telemetry .start_timer(metrics::MEMORY_PHASE_TWO_E2E_MS, &[]) .ok(); @@ -59,7 +59,7 @@ pub(super) async fn run(session: &Arc, config: Arc) { let claim = match job::claim(session, db).await { Ok(claim) => claim, Err(e) => { - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_TWO_JOBS, 1, &[("status", e)], @@ -183,7 +183,7 @@ mod job { session: &Arc, db: &StateRuntime, ) -> Result { - let otel_manager = &session.services.otel_manager; + let session_telemetry = &session.services.session_telemetry; let claim = db .try_claim_global_phase2_job(session.conversation_id, phase_two::JOB_LEASE_SECONDS) .await @@ -196,7 +196,11 @@ mod job { ownership_token, input_watermark, } => { - otel_manager.counter(metrics::MEMORY_PHASE_TWO_JOBS, 1, &[("status", "claimed")]); + session_telemetry.counter( + metrics::MEMORY_PHASE_TWO_JOBS, + 1, + &[("status", "claimed")], + ); (ownership_token, input_watermark) } codex_state::Phase2JobClaimOutcome::SkippedNotDirty => return Err("skipped_not_dirty"), @@ -212,7 +216,7 @@ mod job { claim: &Claim, reason: &'static str, ) { - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_TWO_JOBS, 1, &[("status", reason)], @@ -244,7 +248,7 @@ mod job { selected_outputs: &[codex_state::Stage1Output], reason: &'static str, ) { - session.services.otel_manager.counter( + session.services.session_telemetry.counter( metrics::MEMORY_PHASE_TWO_JOBS, 1, &[("status", reason)], @@ -450,7 +454,7 @@ pub(super) fn get_watermark( } fn emit_metrics(session: &Arc, counters: Counters) { - let otel = session.services.otel_manager.clone(); + let otel = session.services.session_telemetry.clone(); if counters.input > 0 { otel.counter(metrics::MEMORY_PHASE_TWO_INPUT, counters.input, &[]); } @@ -463,7 +467,7 @@ fn emit_metrics(session: &Arc, counters: Counters) { } fn emit_token_usage_metrics(session: &Arc, token_usage: &TokenUsage) { - let otel = session.services.otel_manager.clone(); + let otel = session.services.session_telemetry.clone(); otel.histogram( metrics::MEMORY_PHASE_TWO_TOKEN_USAGE, token_usage.total_tokens.max(0), diff --git a/codex-rs/core/src/memories/usage.rs b/codex-rs/core/src/memories/usage.rs index ff82d6e164..8a86babb42 100644 --- a/codex-rs/core/src/memories/usage.rs +++ b/codex-rs/core/src/memories/usage.rs @@ -39,7 +39,7 @@ pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, succe let success = if success { "true" } else { "false" }; for kind in kinds { - invocation.turn.otel_manager.counter( + invocation.turn.session_telemetry.counter( MEMORIES_USAGE_METRIC, 1, &[ diff --git a/codex-rs/core/src/memory_trace.rs b/codex-rs/core/src/memory_trace.rs index 2e114b9aa4..5cc4994427 100644 --- a/codex-rs/core/src/memory_trace.rs +++ b/codex-rs/core/src/memory_trace.rs @@ -6,7 +6,7 @@ use crate::error::CodexErr; use crate::error::Result; use codex_api::RawMemory as ApiRawMemory; use codex_api::RawMemoryMetadata as ApiRawMemoryMetadata; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use serde_json::Map; @@ -38,7 +38,7 @@ pub async fn build_memories_from_trace_files( trace_paths: &[PathBuf], model_info: &ModelInfo, effort: Option, - otel_manager: &OtelManager, + session_telemetry: &SessionTelemetry, ) -> Result> { if trace_paths.is_empty() { return Ok(Vec::new()); @@ -51,7 +51,7 @@ pub async fn build_memories_from_trace_files( let raw_memories = prepared.iter().map(|trace| trace.payload.clone()).collect(); let output = client - .summarize_memories(raw_memories, model_info, effort, otel_manager) + .summarize_memories(raw_memories, model_info, effort, session_telemetry) .await?; if output.len() != prepared.len() { return Err(CodexErr::InvalidRequest(format!( diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index 66ca9248eb..3d46df0f8a 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -7,7 +7,7 @@ use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Timelike; use chrono::Utc; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; @@ -96,7 +96,7 @@ pub(crate) fn builder_from_items( pub(crate) async fn extract_metadata_from_rollout( rollout_path: &Path, default_provider: &str, - otel: Option<&OtelManager>, + otel: Option<&SessionTelemetry>, ) -> anyhow::Result { let (items, _thread_id, parse_errors) = RolloutRecorder::load_rollout_items(rollout_path).await?; @@ -144,7 +144,7 @@ pub(crate) async fn extract_metadata_from_rollout( pub(crate) async fn backfill_sessions( runtime: &codex_state::StateRuntime, config: &Config, - otel: Option<&OtelManager>, + otel: Option<&SessionTelemetry>, ) { let timer = otel.and_then(|otel| otel.start_timer(DB_METRIC_BACKFILL_DURATION_MS, &[]).ok()); let backfill_state = match runtime.get_backfill_state().await { diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 291129f977..5d285cbfa8 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -14,7 +14,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use tokio::fs; use tokio::process::Command; @@ -40,7 +40,7 @@ impl ShellSnapshot { session_id: ThreadId, session_cwd: PathBuf, shell: &mut Shell, - otel_manager: OtelManager, + session_telemetry: SessionTelemetry, ) -> watch::Sender>> { let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(None); shell.shell_snapshot = shell_snapshot_rx; @@ -51,7 +51,7 @@ impl ShellSnapshot { session_cwd, shell.clone(), shell_snapshot_tx.clone(), - otel_manager, + session_telemetry, ); shell_snapshot_tx @@ -63,7 +63,7 @@ impl ShellSnapshot { session_cwd: PathBuf, shell: Shell, shell_snapshot_tx: watch::Sender>>, - otel_manager: OtelManager, + session_telemetry: SessionTelemetry, ) { Self::spawn_snapshot_task( codex_home, @@ -71,7 +71,7 @@ impl ShellSnapshot { session_cwd, shell, shell_snapshot_tx, - otel_manager, + session_telemetry, ); } @@ -81,12 +81,12 @@ impl ShellSnapshot { session_cwd: PathBuf, snapshot_shell: Shell, shell_snapshot_tx: watch::Sender>>, - otel_manager: OtelManager, + session_telemetry: SessionTelemetry, ) { let snapshot_span = info_span!("shell_snapshot", thread_id = %session_id); tokio::spawn( async move { - let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); + let timer = session_telemetry.start_timer("codex.shell_snapshot.duration_ms", &[]); let snapshot = ShellSnapshot::try_new( &codex_home, session_id, @@ -102,7 +102,7 @@ impl ShellSnapshot { if let Some(failure_reason) = snapshot.as_ref().err() { counter_tags.push(("failure_reason", *failure_reason)); } - otel_manager.counter("codex.shell_snapshot", 1, &counter_tags); + session_telemetry.counter("codex.shell_snapshot", 1, &counter_tags); let _ = shell_snapshot_tx.send(snapshot.ok()); } .instrument(snapshot_span), diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index de50d6cddc..ac55f3b8f2 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -9,7 +9,7 @@ use crate::analytics_client::TrackEventsContext; use crate::instructions::SkillInstructions; use crate::mentions::build_skill_name_counts; use crate::skills::SkillMetadata; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use tokio::fs; @@ -22,7 +22,7 @@ pub(crate) struct SkillInjections { pub(crate) async fn build_skill_injections( mentioned_skills: &[SkillMetadata], - otel: Option<&OtelManager>, + otel: Option<&SessionTelemetry>, analytics_client: &AnalyticsEventsClient, tracking: TrackEventsContext, ) -> SkillInjections { @@ -69,7 +69,11 @@ pub(crate) async fn build_skill_injections( result } -fn emit_skill_injected_metric(otel: Option<&OtelManager>, skill: &SkillMetadata, status: &str) { +fn emit_skill_injected_metric( + otel: Option<&SessionTelemetry>, + skill: &SkillMetadata, + status: &str, +) { let Some(otel) = otel else { return; }; diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core/src/skills/invocation_utils.rs index 36bb0c3caa..c4310baceb 100644 --- a/codex-rs/core/src/skills/invocation_utils.rs +++ b/codex-rs/core/src/skills/invocation_utils.rs @@ -94,7 +94,7 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation( return; } - turn_context.otel_manager.counter( + turn_context.session_telemetry.counter( "codex.skill.injected", 1, &[ diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index e2792638e8..012e17bbf6 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -20,7 +20,7 @@ use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; use codex_hooks::Hooks; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::PathBuf; use tokio::sync::Mutex; @@ -45,7 +45,7 @@ pub(crate) struct SessionServices { pub(crate) exec_policy: ExecPolicyManager, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, - pub(crate) otel_manager: OtelManager, + pub(crate) session_telemetry: SessionTelemetry, pub(crate) tool_approvals: Mutex, #[cfg_attr(not(unix), allow(dead_code))] pub(crate) execve_session_approvals: RwLock>, diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index 2a9fa5e451..922331e3c5 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -7,7 +7,7 @@ use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Timelike; use chrono::Utc; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::protocol::RolloutItem; @@ -26,7 +26,10 @@ pub type StateDbHandle = Arc; /// Initialize the state runtime for thread state persistence and backfill checks. To only be used /// inside `core`. The initialization should not be done anywhere else. -pub(crate) async fn init(config: &Config, otel: Option<&OtelManager>) -> Option { +pub(crate) async fn init( + config: &Config, + otel: Option<&SessionTelemetry>, +) -> Option { let runtime = match codex_state::StateRuntime::init( config.sqlite_home.clone(), config.model_provider_id.clone(), @@ -69,7 +72,10 @@ pub(crate) async fn init(config: &Config, otel: Option<&OtelManager>) -> Option< } /// Get the DB if the feature is enabled and the DB exists. -pub async fn get_state_db(config: &Config, otel: Option<&OtelManager>) -> Option { +pub async fn get_state_db( + config: &Config, + otel: Option<&SessionTelemetry>, +) -> Option { let state_path = codex_state::state_db_path(config.sqlite_home.as_path()); if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) { return None; diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 7f2ddb251e..106f881054 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -220,7 +220,7 @@ pub(crate) async fn handle_output_item_done( Err(FunctionCallError::MissingLocalShellCallId) => { let msg = "LocalShellCall without call_id or id"; ctx.turn_context - .otel_manager + .session_telemetry .log_tool_failed("local_shell", msg); tracing::error!(msg); diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index a8c0a5e06d..be2fcca854 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -30,14 +30,14 @@ impl SessionTask for CompactTask { ) -> Option { let session = session.clone_session(); let _ = if crate::compact::should_use_remote_compact_task(&ctx.provider) { - let _ = session.services.otel_manager.counter( + let _ = session.services.session_telemetry.counter( "codex.task.compact", 1, &[("type", "remote")], ); crate::compact_remote::run_remote_compact_task(session.clone(), ctx).await } else { - let _ = session.services.otel_manager.counter( + let _ = session.services.session_telemetry.counter( "codex.task.compact", 1, &[("type", "local")], diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f6962edfd7..a07e52f730 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -144,7 +144,7 @@ impl Session { let done = Arc::new(Notify::new()); let timer = turn_context - .otel_manager + .session_telemetry .start_timer("codex.turn.e2e_duration_ms", &[]) .ok(); @@ -272,7 +272,7 @@ impl Session { "false" }, ); - self.services.otel_manager.histogram( + self.services.session_telemetry.histogram( "codex.turn.tool.call", i64::try_from(turn_tool_calls).unwrap_or(i64::MAX), &[tmp_mem], @@ -295,27 +295,27 @@ impl Session { - token_usage_at_turn_start.total_tokens) .max(0), }; - self.services.otel_manager.histogram( + self.services.session_telemetry.histogram( "codex.turn.token_usage", turn_token_usage.total_tokens, &[("token_type", "total"), tmp_mem], ); - self.services.otel_manager.histogram( + self.services.session_telemetry.histogram( "codex.turn.token_usage", turn_token_usage.input_tokens, &[("token_type", "input"), tmp_mem], ); - self.services.otel_manager.histogram( + self.services.session_telemetry.histogram( "codex.turn.token_usage", turn_token_usage.cached_input(), &[("token_type", "cached_input"), tmp_mem], ); - self.services.otel_manager.histogram( + self.services.session_telemetry.histogram( "codex.turn.token_usage", turn_token_usage.output_tokens, &[("token_type", "output"), tmp_mem], ); - self.services.otel_manager.histogram( + self.services.session_telemetry.histogram( "codex.turn.token_usage", turn_token_usage.reasoning_output_tokens, &[("token_type", "reasoning_output"), tmp_mem], diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index e3a9ef4c38..78bbd1db4c 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -41,7 +41,7 @@ impl RegularTask { .prewarm_websocket( &prompt, &turn_context.model_info, - &turn_context.otel_manager, + &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, turn_context.config.service_tier, diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 4787ee7826..51ac3a1d31 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -57,7 +57,7 @@ impl SessionTask for ReviewTask { let _ = session .session .services - .otel_manager + .session_telemetry .counter("codex.task.review", 1, &[]); // Start sub-codex conversation and get the receiver for events. diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs index 95f51c1ec3..05cd928b5f 100644 --- a/codex-rs/core/src/tasks/undo.rs +++ b/codex-rs/core/src/tasks/undo.rs @@ -45,7 +45,7 @@ impl SessionTask for UndoTask { let _ = session .session .services - .otel_manager + .session_telemetry .counter("codex.task.undo", 1, &[]); let sess = session.clone_session(); sess.send_event( diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 2f77d9fcee..a6a1301cd7 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -98,7 +98,7 @@ pub(crate) async fn execute_user_shell_command( ) { session .services - .otel_manager + .session_telemetry .counter("codex.task.user_shell", 1, &[]); if mode == UserShellCommandMode::StandaloneTurn { diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 20b001315b..1d3805b322 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -214,7 +214,7 @@ mod spawn { .await; let new_thread_id = result?; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.otel_manager + turn.session_telemetry .counter("codex.multi_agent.spawn", 1, &[("role", role_tag)]); let content = serde_json::to_string(&SpawnAgentResult { @@ -425,7 +425,7 @@ mod resume_agent { if let Some(err) = error { return Err(err); } - turn.otel_manager + turn.session_telemetry .counter("codex.multi_agent.resume", 1, &[]); let content = serde_json::to_string(&ResumeAgentResult { status }).map_err(|err| { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index f66c79bbb0..e78f0bcfce 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -108,7 +108,7 @@ impl ToolOrchestrator { where T: ToolRuntime, { - let otel = turn_ctx.otel_manager.clone(); + let otel = turn_ctx.session_telemetry.clone(); let otel_tn = &tool_ctx.tool_name; let otel_ci = &tool_ctx.call_id; let otel_user = ToolDecisionSource::User; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 77d3c4150b..74a44f1db7 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -82,7 +82,7 @@ impl ToolRegistry { ) -> Result { let tool_name = invocation.tool_name.clone(); let call_id_owned = invocation.call_id.clone(); - let otel = invocation.turn.otel_manager.clone(); + let otel = invocation.turn.session_telemetry.clone(); let payload_for_response = invocation.payload.clone(); let log_payload = payload_for_response.log_payload(); let metric_tags = [ diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 28d87b5bf3..df8f7c152b 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -88,7 +88,7 @@ where let decision = fetch().await; - services.otel_manager.counter( + services.session_telemetry.counter( "codex.approval.requested", 1, &[ diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index b825fde948..765373c35e 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -21,7 +21,7 @@ pub(crate) async fn record_turn_ttft_metric(turn_context: &TurnContext, event: & return; }; turn_context - .otel_manager + .session_telemetry .record_duration(TURN_TTFT_DURATION_METRIC, duration, &[]); } @@ -34,7 +34,7 @@ pub(crate) async fn record_turn_ttfm_metric(turn_context: &TurnContext, item: &T return; }; turn_context - .otel_manager + .session_telemetry .record_duration(TURN_TTFM_DURATION_METRIC, duration, &[]); } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index be41a25a0f..d5376fcd7d 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -7,7 +7,7 @@ use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::WireApi; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; @@ -72,7 +72,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let session_source = SessionSource::SubAgent(SubAgentSource::Review); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let otel_manager = OtelManager::new( + let session_telemetry = SessionTelemetry::new( conversation_id, model.as_str(), model_info.slug.as_str(), @@ -113,7 +113,7 @@ async fn responses_stream_includes_subagent_header_on_review() { .stream( &prompt, &model_info, - &otel_manager, + &session_telemetry, effort, summary.unwrap_or(model_info.default_reasoning_summary), None, @@ -185,7 +185,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let otel_manager = OtelManager::new( + let session_telemetry = SessionTelemetry::new( conversation_id, model.as_str(), model_info.slug.as_str(), @@ -226,7 +226,7 @@ async fn responses_stream_includes_subagent_header_on_other() { .stream( &prompt, &model_info, - &otel_manager, + &session_telemetry, effort, summary.unwrap_or(model_info.default_reasoning_summary), None, @@ -297,7 +297,7 @@ async fn responses_respects_model_info_overrides_from_config() { SessionSource::SubAgent(SubAgentSource::Other("override-check".to_string())); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let otel_manager = OtelManager::new( + let session_telemetry = SessionTelemetry::new( conversation_id, model.as_str(), model_info.slug.as_str(), @@ -338,7 +338,7 @@ async fn responses_respects_model_info_overrides_from_config() { .stream( &prompt, &model_info, - &otel_manager, + &session_telemetry, effort, summary.unwrap_or(model_info.default_reasoning_summary), None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3ac9a72c3c..30eac507cf 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -12,7 +12,7 @@ use codex_core::default_client::originator; use codex_core::error::CodexErr; use codex_core::features::Feature; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; @@ -1752,7 +1752,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let conversation_id = ThreadId::new(); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")); - let otel_manager = OtelManager::new( + let session_telemetry = SessionTelemetry::new( conversation_id, model.as_str(), model_info.slug.as_str(), @@ -1844,7 +1844,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { .stream( &prompt, &model_info, - &otel_manager, + &session_telemetry, effort, summary.unwrap_or(ReasoningSummary::Auto), None, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 4896a0fa1c..cda634448c 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -9,7 +9,7 @@ use codex_core::WireApi; use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; use codex_core::features::Feature; use codex_core::ws_version_from_features; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::metrics::MetricsClient; use codex_otel::metrics::MetricsConfig; @@ -56,7 +56,7 @@ struct WebsocketTestHarness { model_info: ModelInfo, effort: Option, summary: ReasoningSummary, - otel_manager: OtelManager, + session_telemetry: SessionTelemetry, } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -105,7 +105,7 @@ async fn responses_websocket_preconnect_reuses_connection() { let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); client_session - .preconnect_websocket(&harness.otel_manager, &harness.model_info) + .preconnect_websocket(&harness.session_telemetry, &harness.model_info) .await .expect("websocket preconnect failed"); let prompt = prompt_with_input(vec![message_item("hello")]); @@ -134,7 +134,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { .prewarm_websocket( &prompt, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, None, @@ -207,7 +207,7 @@ async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); client_session - .preconnect_websocket(&harness.otel_manager, &harness.model_info) + .preconnect_websocket(&harness.session_telemetry, &harness.model_info) .await .expect("websocket preconnect failed"); let prompt = prompt_with_input(vec![message_item("hello")]); @@ -215,7 +215,7 @@ async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { .stream( &prompt, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, None, @@ -253,7 +253,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( .prewarm_websocket( &prompt, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, None, @@ -265,7 +265,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( .stream( &prompt, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, None, @@ -318,7 +318,7 @@ async fn responses_websocket_prewarm_uses_v2_when_model_prefers_websockets_and_f .prewarm_websocket( &prompt, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, None, @@ -371,7 +371,7 @@ async fn responses_websocket_preconnect_runs_when_only_v2_feature_enabled() { let harness = websocket_harness_with_options(&server, false, false, true, false).await; let mut client_session = harness.client.new_session(); client_session - .preconnect_websocket(&harness.otel_manager, &harness.model_info) + .preconnect_websocket(&harness.session_telemetry, &harness.model_info) .await .expect("websocket preconnect failed"); @@ -551,7 +551,7 @@ async fn responses_websocket_emits_websocket_telemetry_events() { .await; let harness = websocket_harness(&server).await; - harness.otel_manager.reset_runtime_metrics(); + harness.session_telemetry.reset_runtime_metrics(); let mut client_session = harness.client.new_session(); let prompt = prompt_with_input(vec![message_item("hello")]); @@ -560,7 +560,7 @@ async fn responses_websocket_emits_websocket_telemetry_events() { tokio::time::sleep(Duration::from_millis(10)).await; let summary = harness - .otel_manager + .session_telemetry .runtime_metrics_summary() .expect("runtime metrics summary"); assert_eq!(summary.api_calls.count, 0); @@ -593,7 +593,7 @@ async fn responses_websocket_includes_timing_metrics_header_when_runtime_metrics .await; let harness = websocket_harness_with_runtime_metrics(&server, true).await; - harness.otel_manager.reset_runtime_metrics(); + harness.session_telemetry.reset_runtime_metrics(); let mut client_session = harness.client.new_session(); let prompt = prompt_with_input(vec![message_item("hello")]); @@ -607,7 +607,7 @@ async fn responses_websocket_includes_timing_metrics_header_when_runtime_metrics ); let summary = harness - .otel_manager + .session_telemetry .runtime_metrics_summary() .expect("runtime metrics summary"); assert_eq!(summary.responses_api_overhead_ms, 120); @@ -664,7 +664,7 @@ async fn responses_websocket_emits_reasoning_included_event() { .stream( &prompt, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, None, @@ -736,7 +736,7 @@ async fn responses_websocket_emits_rate_limit_events() { .stream( &prompt, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, None, @@ -1316,7 +1316,7 @@ async fn responses_websocket_v2_after_error_uses_full_create_without_previous_re .stream( &prompt_two, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, None, @@ -1515,7 +1515,7 @@ async fn websocket_harness_with_options( .with_runtime_reader(), ) .expect("in-memory metrics client"); - let otel_manager = OtelManager::new( + let session_telemetry = SessionTelemetry::new( conversation_id, MODEL, model_info.slug.as_str(), @@ -1548,7 +1548,7 @@ async fn websocket_harness_with_options( model_info, effort, summary, - otel_manager, + session_telemetry, } } @@ -1581,7 +1581,7 @@ async fn stream_until_complete_with_turn_metadata( .stream( prompt, &harness.model_info, - &harness.otel_manager, + &harness.session_telemetry, harness.effort, harness.summary, service_tier, diff --git a/codex-rs/otel/README.md b/codex-rs/otel/README.md index 02d4fc3a0e..be90d61416 100644 --- a/codex-rs/otel/README.md +++ b/codex-rs/otel/README.md @@ -4,7 +4,7 @@ - Provider wiring for log/trace/metric exporters (`codex_otel::OtelProvider`, `codex_otel::provider`, and the compatibility shim `codex_otel::otel_provider`). -- Session-scoped business event emission via `codex_otel::OtelManager`. +- Session-scoped business event emission via `codex_otel::SessionTelemetry`. - Low-level metrics APIs via `codex_otel::metrics`. - Trace-context helpers via `codex_otel::trace_context` and crate-root re-exports. @@ -49,16 +49,16 @@ if let Some(provider) = OtelProvider::from(&settings)? { } ``` -## OtelManager (events) +## SessionTelemetry (events) -`OtelManager` adds consistent metadata to tracing events and helps record +`SessionTelemetry` adds consistent metadata to tracing events and helps record Codex-specific session events. Rich session/business events should go through -`OtelManager`; subsystem-owned audit events can stay with the owning subsystem. +`SessionTelemetry`; subsystem-owned audit events can stay with the owning subsystem. ```rust -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; -let manager = OtelManager::new( +let manager = SessionTelemetry::new( conversation_id, model, slug, @@ -134,7 +134,7 @@ use codex_otel::set_parent_from_w3c_trace_context; ## Shutdown - `OtelProvider::shutdown()` stops the OTEL exporter. -- `OtelManager::shutdown_metrics()` flushes and shuts down the metrics provider. +- `SessionTelemetry::shutdown_metrics()` flushes and shuts down the metrics provider. Both are optional because drop performs best-effort shutdown, but calling them explicitly gives deterministic flushing (or a shutdown error if flushing does diff --git a/codex-rs/otel/src/events/mod.rs b/codex-rs/otel/src/events/mod.rs index cd2c922e59..b0254c92cd 100644 --- a/codex-rs/otel/src/events/mod.rs +++ b/codex-rs/otel/src/events/mod.rs @@ -1,2 +1,2 @@ -pub(crate) mod otel_manager; +pub(crate) mod session_telemetry; pub(crate) mod shared; diff --git a/codex-rs/otel/src/events/otel_manager.rs b/codex-rs/otel/src/events/session_telemetry.rs similarity index 98% rename from codex-rs/otel/src/events/otel_manager.rs rename to codex-rs/otel/src/events/session_telemetry.rs index bfe477f562..50d7614a0f 100644 --- a/codex-rs/otel/src/events/otel_manager.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -64,7 +64,7 @@ const RESPONSES_API_ENGINE_IAPI_TBT_FIELD: &str = "engine_iapi_tbt_across_engine const RESPONSES_API_ENGINE_SERVICE_TBT_FIELD: &str = "engine_service_tbt_across_engine_calls_ms"; #[derive(Debug, Clone)] -pub struct OtelEventMetadata { +pub struct SessionTelemetryMetadata { pub(crate) conversation_id: ThreadId, pub(crate) auth_mode: Option, pub(crate) account_id: Option, @@ -80,13 +80,13 @@ pub struct OtelEventMetadata { } #[derive(Debug, Clone)] -pub struct OtelManager { - pub(crate) metadata: OtelEventMetadata, +pub struct SessionTelemetry { + pub(crate) metadata: SessionTelemetryMetadata, pub(crate) metrics: Option, pub(crate) metrics_use_metadata_tags: bool, } -impl OtelManager { +impl SessionTelemetry { pub fn with_model(mut self, model: &str, slug: &str) -> Self { self.metadata.model = model.to_owned(); self.metadata.slug = slug.to_owned(); @@ -276,9 +276,9 @@ impl OtelManager { log_user_prompts: bool, terminal_type: String, session_source: SessionSource, - ) -> OtelManager { + ) -> SessionTelemetry { Self { - metadata: OtelEventMetadata { + metadata: SessionTelemetryMetadata { conversation_id, auth_mode: auth_mode.map(|m| m.to_string()), account_id, @@ -298,7 +298,7 @@ impl OtelManager { } pub fn record_responses(&self, handle_responses_span: &Span, event: &ResponseEvent) { - handle_responses_span.record("otel.name", OtelManager::responses_type(event)); + handle_responses_span.record("otel.name", SessionTelemetry::responses_type(event)); match event { ResponseEvent::OutputItemDone(item) => { @@ -902,7 +902,7 @@ impl OtelManager { match event { ResponseEvent::Created => "created".into(), ResponseEvent::OutputItemDone(item) | ResponseEvent::OutputItemAdded(item) => { - OtelManager::responses_item_type(item) + SessionTelemetry::responses_item_type(item) } ResponseEvent::Completed { .. } => "completed".into(), ResponseEvent::OutputTextDelta(_) => "text_delta".into(), diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 5594b26a7e..5a4ba31e44 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -13,8 +13,8 @@ use crate::metrics::Result as MetricsResult; use serde::Serialize; use strum_macros::Display; -pub use crate::events::otel_manager::OtelEventMetadata; -pub use crate::events::otel_manager::OtelManager; +pub use crate::events::session_telemetry::SessionTelemetry; +pub use crate::events::session_telemetry::SessionTelemetryMetadata; pub use crate::metrics::runtime_metrics::RuntimeMetricTotals; pub use crate::metrics::runtime_metrics::RuntimeMetricsSummary; pub use crate::metrics::timer::Timer; diff --git a/codex-rs/otel/tests/suite/manager_metrics.rs b/codex-rs/otel/tests/suite/manager_metrics.rs index 53a9cc89d6..a3160d5a4b 100644 --- a/codex-rs/otel/tests/suite/manager_metrics.rs +++ b/codex-rs/otel/tests/suite/manager_metrics.rs @@ -2,7 +2,7 @@ use crate::harness::attributes_to_map; use crate::harness::build_metrics_with_defaults; use crate::harness::find_metric; use crate::harness::latest_metrics; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::metrics::Result; use codex_protocol::ThreadId; @@ -12,11 +12,11 @@ use opentelemetry_sdk::metrics::data::MetricData; use pretty_assertions::assert_eq; use std::collections::BTreeMap; -// Ensures OtelManager attaches metadata tags when forwarding metrics. +// Ensures SessionTelemetry attaches metadata tags when forwarding metrics. #[test] fn manager_attaches_metadata_tags_to_metrics() -> Result<()> { let (metrics, exporter) = build_metrics_with_defaults(&[("service", "codex-cli")])?; - let manager = OtelManager::new( + let manager = SessionTelemetry::new( ThreadId::new(), "gpt-5.1", "gpt-5.1", @@ -68,11 +68,11 @@ fn manager_attaches_metadata_tags_to_metrics() -> Result<()> { Ok(()) } -// Ensures metadata tagging can be disabled when recording via OtelManager. +// Ensures metadata tagging can be disabled when recording via SessionTelemetry. #[test] fn manager_allows_disabling_metadata_tags() -> Result<()> { let (metrics, exporter) = build_metrics_with_defaults(&[])?; - let manager = OtelManager::new( + let manager = SessionTelemetry::new( ThreadId::new(), "gpt-4o", "gpt-4o", @@ -113,7 +113,7 @@ fn manager_allows_disabling_metadata_tags() -> Result<()> { #[test] fn manager_attaches_optional_service_name_tag() -> Result<()> { let (metrics, exporter) = build_metrics_with_defaults(&[])?; - let manager = OtelManager::new( + let manager = SessionTelemetry::new( ThreadId::new(), "gpt-5.1", "gpt-5.1", diff --git a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs index 875bfd6668..75d9bde83c 100644 --- a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs +++ b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs @@ -1,4 +1,4 @@ -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::otel_provider::OtelProvider; use opentelemetry::KeyValue; @@ -103,7 +103,7 @@ fn otel_export_routing_policy_routes_user_prompt_log_and_trace_events() { tracing::subscriber::with_default(subscriber, || { tracing::callsite::rebuild_interest_cache(); - let manager = OtelManager::new( + let manager = SessionTelemetry::new( ThreadId::new(), "gpt-5.1", "gpt-5.1", @@ -212,7 +212,7 @@ fn otel_export_routing_policy_routes_tool_result_log_and_trace_events() { tracing::subscriber::with_default(subscriber, || { tracing::callsite::rebuild_interest_cache(); - let manager = OtelManager::new( + let manager = SessionTelemetry::new( ThreadId::new(), "gpt-5.1", "gpt-5.1", diff --git a/codex-rs/otel/tests/suite/runtime_summary.rs b/codex-rs/otel/tests/suite/runtime_summary.rs index 8857387d51..c2f252381f 100644 --- a/codex-rs/otel/tests/suite/runtime_summary.rs +++ b/codex-rs/otel/tests/suite/runtime_summary.rs @@ -1,6 +1,6 @@ -use codex_otel::OtelManager; use codex_otel::RuntimeMetricTotals; use codex_otel::RuntimeMetricsSummary; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::metrics::MetricsClient; use codex_otel::metrics::MetricsConfig; @@ -20,7 +20,7 @@ fn runtime_metrics_summary_collects_tool_api_and_streaming_metrics() -> Result<( MetricsConfig::in_memory("test", "codex-cli", env!("CARGO_PKG_VERSION"), exporter) .with_runtime_reader(), )?; - let manager = OtelManager::new( + let manager = SessionTelemetry::new( ThreadId::new(), "gpt-5.1", "gpt-5.1", diff --git a/codex-rs/otel/tests/suite/snapshot.rs b/codex-rs/otel/tests/suite/snapshot.rs index e13775af64..6bd9bd8190 100644 --- a/codex-rs/otel/tests/suite/snapshot.rs +++ b/codex-rs/otel/tests/suite/snapshot.rs @@ -1,6 +1,6 @@ use crate::harness::attributes_to_map; use crate::harness::find_metric; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::metrics::MetricsClient; use codex_otel::metrics::MetricsConfig; @@ -69,7 +69,7 @@ fn manager_snapshot_metrics_collects_without_shutdown() -> Result<()> { .with_tag("service", "codex-cli")? .with_runtime_reader(); let metrics = MetricsClient::new(config)?; - let manager = OtelManager::new( + let manager = SessionTelemetry::new( ThreadId::new(), "gpt-5.1", "gpt-5.1", diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index a32f041ce1..647565b735 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -28,7 +28,7 @@ use crate::model::datetime_to_epoch_seconds; use crate::paths::file_modified_time_utc; use chrono::DateTime; use chrono::Utc; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::protocol::RolloutItem; @@ -84,7 +84,7 @@ impl StateRuntime { pub async fn init( codex_home: PathBuf, default_provider: String, - otel: Option, + otel: Option, ) -> anyhow::Result> { tokio::fs::create_dir_all(&codex_home).await?; let current_state_name = state_db_filename(); diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index e671dfaf62..8825f13fe3 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -447,7 +447,7 @@ ON CONFLICT(thread_id, position) DO NOTHING &self, builder: &ThreadMetadataBuilder, items: &[RolloutItem], - otel: Option<&OtelManager>, + otel: Option<&SessionTelemetry>, new_thread_memory_mode: Option<&str>, updated_at_override: Option>, ) -> anyhow::Result<()> { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 32f5d59635..9608cf563e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -57,7 +57,7 @@ use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_ use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; -use codex_otel::OtelManager; +use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; @@ -637,7 +637,7 @@ async fn handle_model_migration_prompt_if_needed( pub(crate) struct App { pub(crate) server: Arc, - pub(crate) otel_manager: OtelManager, + pub(crate) session_telemetry: SessionTelemetry, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, pub(crate) auth_manager: Arc, @@ -750,7 +750,7 @@ impl App { model: Some(self.chat_widget.current_model().to_string()), startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), - otel_manager: self.otel_manager.clone(), + session_telemetry: self.session_telemetry.clone(), } } @@ -1437,7 +1437,7 @@ impl App { model: Some(model), startup_tooltip_override: None, status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), - otel_manager: self.otel_manager.clone(), + session_telemetry: self.session_telemetry.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); self.reset_thread_event_state(); @@ -1624,7 +1624,7 @@ impl App { let auth_mode = auth_ref .map(CodexAuth::auth_mode) .map(TelemetryAuthMode::from); - let otel_manager = OtelManager::new( + let session_telemetry = SessionTelemetry::new( ThreadId::new(), model.as_str(), model.as_str(), @@ -1641,7 +1641,7 @@ impl App { .as_ref() .is_some_and(|cmd| !cmd.is_empty()) { - otel_manager.counter("codex.status_line", 1, &[]); + session_telemetry.counter("codex.status_line", 1, &[]); } let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); @@ -1673,7 +1673,7 @@ impl App { model: Some(model.clone()), startup_tooltip_override, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), - otel_manager: otel_manager.clone(), + session_telemetry: session_telemetry.clone(), }; ChatWidget::new(init, thread_manager.clone()) } @@ -1708,12 +1708,12 @@ impl App { model: config.model.clone(), startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), - otel_manager: otel_manager.clone(), + session_telemetry: session_telemetry.clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } SessionSelection::Fork(target_session) => { - otel_manager.counter("codex.thread.fork", 1, &[("source", "cli_subcommand")]); + session_telemetry.counter("codex.thread.fork", 1, &[("source", "cli_subcommand")]); let forked = thread_manager .fork_thread( usize::MAX, @@ -1745,7 +1745,7 @@ impl App { model: config.model.clone(), startup_tooltip_override: None, status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), - otel_manager: otel_manager.clone(), + session_telemetry: session_telemetry.clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } @@ -1760,7 +1760,7 @@ impl App { let mut app = Self { server: thread_manager.clone(), - otel_manager: otel_manager.clone(), + session_telemetry: session_telemetry.clone(), app_event_tx, chat_widget, auth_manager: auth_manager.clone(), @@ -2081,8 +2081,11 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::ForkCurrentSession => { - self.otel_manager - .counter("codex.thread.fork", 1, &[("source", "slash_command")]); + self.session_telemetry.counter( + "codex.thread.fork", + 1, + &[("source", "slash_command")], + ); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), @@ -2343,11 +2346,14 @@ impl App { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => { - self.otel_manager - .counter("codex.windows_sandbox.fallback_prompt_shown", 1, &[]); + self.session_telemetry.counter( + "codex.windows_sandbox.fallback_prompt_shown", + 1, + &[], + ); self.chat_widget.clear_windows_sandbox_setup_status(); if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { - self.otel_manager.record_duration( + self.session_telemetry.record_duration( "codex.windows_sandbox.elevated_setup_duration_ms", started_at.elapsed(), &[("result", "failure")], @@ -2380,7 +2386,7 @@ impl App { self.chat_widget.show_windows_sandbox_setup_status(); self.windows_sandbox.setup_started_at = Some(Instant::now()); - let otel_manager = self.otel_manager.clone(); + let session_telemetry = self.session_telemetry.clone(); tokio::task::spawn_blocking(move || { let result = codex_core::windows_sandbox::run_elevated_setup( &policy, @@ -2391,7 +2397,7 @@ impl App { ); let event = match result { Ok(()) => { - otel_manager.counter( + session_telemetry.counter( "codex.windows_sandbox.elevated_setup_success", 1, &[], @@ -2419,7 +2425,7 @@ impl App { if let Some(message) = message_tag.as_deref() { tags.push(("message", message)); } - otel_manager.counter( + session_telemetry.counter( codex_core::windows_sandbox::elevated_setup_failure_metric_name( &err, ), @@ -2451,7 +2457,7 @@ impl App { std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); let tx = self.app_event_tx.clone(); - let otel_manager = self.otel_manager.clone(); + let session_telemetry = self.session_telemetry.clone(); self.chat_widget.show_windows_sandbox_setup_status(); tokio::task::spawn_blocking(move || { @@ -2462,7 +2468,7 @@ impl App { &env_map, codex_home.as_path(), ) { - otel_manager.counter( + session_telemetry.counter( "codex.windows_sandbox.legacy_setup_preflight_failed", 1, &[], @@ -2545,7 +2551,7 @@ impl App { { self.chat_widget.clear_windows_sandbox_setup_status(); if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { - self.otel_manager.record_duration( + self.session_telemetry.record_duration( "codex.windows_sandbox.elevated_setup_duration_ms", started_at.elapsed(), &[("result", "success")], @@ -3713,7 +3719,7 @@ mod tests { use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::types::ModelAvailabilityNuxConfig; - use codex_otel::OtelManager; + use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; @@ -5276,11 +5282,11 @@ mod tests { ); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); - let otel_manager = test_otel_manager(&config, model.as_str()); + let session_telemetry = test_session_telemetry(&config, model.as_str()); App { server, - otel_manager, + session_telemetry, app_event_tx, chat_widget, auth_manager, @@ -5335,12 +5341,12 @@ mod tests { ); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); - let otel_manager = test_otel_manager(&config, model.as_str()); + let session_telemetry = test_session_telemetry(&config, model.as_str()); ( App { server, - otel_manager, + session_telemetry, app_event_tx, chat_widget, auth_manager, @@ -5391,9 +5397,9 @@ mod tests { panic!("expected UserTurn op, saw: {seen:?}"); } - fn test_otel_manager(config: &Config, model: &str) -> OtelManager { + fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { let model_info = codex_core::test_support::construct_model_info_offline(model, config); - OtelManager::new( + SessionTelemetry::new( ThreadId::new(), model, model_info.slug.as_str(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9f2295e650..ae35225b6b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -74,8 +74,8 @@ use codex_core::terminal::TerminalName; use codex_core::terminal::terminal_info; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; -use codex_otel::OtelManager; use codex_otel::RuntimeMetricsSummary; +use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; @@ -478,7 +478,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) startup_tooltip_override: Option, // Shared latch so we only warn once about invalid status-line item IDs. pub(crate) status_line_invalid_items_warned: Arc, - pub(crate) otel_manager: OtelManager, + pub(crate) session_telemetry: SessionTelemetry, } #[derive(Default)] @@ -560,7 +560,7 @@ pub(crate) struct ChatWidget { active_collaboration_mask: Option, auth_manager: Arc, models_manager: Arc, - otel_manager: OtelManager, + session_telemetry: SessionTelemetry, session_header: SessionHeader, initial_user_message: Option, token_info: Option, @@ -1145,7 +1145,7 @@ impl ChatWidget { } fn collect_runtime_metrics_delta(&mut self) { - if let Some(delta) = self.otel_manager.runtime_metrics_summary() { + if let Some(delta) = self.session_telemetry.runtime_metrics_summary() { self.apply_runtime_metrics_delta(delta); } } @@ -1506,7 +1506,7 @@ impl ChatWidget { self.adaptive_chunking.reset(); self.plan_stream_controller = None; self.turn_runtime_metrics = RuntimeMetricsSummary::default(); - self.otel_manager.reset_runtime_metrics(); + self.session_telemetry.reset_runtime_metrics(); self.bottom_pane.clear_quit_shortcut_hint(); self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; @@ -3044,7 +3044,7 @@ impl ChatWidget { model, startup_tooltip_override, status_line_invalid_items_warned, - otel_manager, + session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; @@ -3103,7 +3103,7 @@ impl ChatWidget { active_collaboration_mask, auth_manager, models_manager, - otel_manager, + session_telemetry, session_header: SessionHeader::new(header_model), initial_user_message, token_info: None, @@ -3227,7 +3227,7 @@ impl ChatWidget { model, startup_tooltip_override, status_line_invalid_items_warned, - otel_manager, + session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; @@ -3285,7 +3285,7 @@ impl ChatWidget { active_collaboration_mask, auth_manager, models_manager, - otel_manager, + session_telemetry, session_header: SessionHeader::new(header_model), initial_user_message, token_info: None, @@ -3401,7 +3401,7 @@ impl ChatWidget { model, startup_tooltip_override: _, status_line_invalid_items_warned, - otel_manager, + session_telemetry, } = common; let model = model.filter(|m| !m.trim().is_empty()); let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); @@ -3459,7 +3459,7 @@ impl ChatWidget { active_collaboration_mask, auth_manager, models_manager, - otel_manager, + session_telemetry, session_header: SessionHeader::new(header_model), initial_user_message, token_info: None, @@ -3840,7 +3840,8 @@ impl ChatWidget { self.open_review_popup(); } SlashCommand::Rename => { - self.otel_manager.counter("codex.thread.rename", 1, &[]); + self.session_telemetry + .counter("codex.thread.rename", 1, &[]); self.show_rename_prompt(); } SlashCommand::Model => { @@ -3942,7 +3943,7 @@ impl ChatWidget { return; } - self.otel_manager.counter( + self.session_telemetry.counter( "codex.windows_sandbox.setup_elevated_sandbox_command", 1, &[], @@ -3952,7 +3953,7 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] { - let _ = &self.otel_manager; + let _ = &self.session_telemetry; // Not supported; on non-Windows this command should never be reachable. }; } @@ -4156,7 +4157,8 @@ impl ChatWidget { } } SlashCommand::Rename if !trimmed.is_empty() => { - self.otel_manager.counter("codex.thread.rename", 1, &[]); + self.session_telemetry + .counter("codex.thread.rename", 1, &[]); let Some((prepared_args, _prepared_elements)) = self.bottom_pane.prepare_inline_args_submission(false) else { @@ -6929,7 +6931,7 @@ impl ChatWidget { return; } - self.otel_manager + self.session_telemetry .counter("codex.windows_sandbox.elevated_prompt_shown", 1, &[]); let mut header = ColumnRenderable::new(); @@ -6940,10 +6942,10 @@ impl ChatWidget { .wrap(Wrap { trim: false }), )); - let accept_otel = self.otel_manager.clone(); - let legacy_otel = self.otel_manager.clone(); + let accept_otel = self.session_telemetry.clone(); + let legacy_otel = self.session_telemetry.clone(); let legacy_preset = preset.clone(); - let quit_otel = self.otel_manager.clone(); + let quit_otel = self.session_telemetry.clone(); let items = vec![ SelectionItem { name: "Set up default sandbox (requires Administrator permissions)".to_string(), @@ -7014,13 +7016,13 @@ impl ChatWidget { let elevated_preset = preset.clone(); let legacy_preset = preset; - let quit_otel = self.otel_manager.clone(); + let quit_otel = self.session_telemetry.clone(); let items = vec![ SelectionItem { name: "Try setting up admin sandbox again".to_string(), description: None, actions: vec![Box::new({ - let otel = self.otel_manager.clone(); + let otel = self.session_telemetry.clone(); let preset = elevated_preset; move |tx| { otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]); @@ -7036,7 +7038,7 @@ impl ChatWidget { name: "Use Codex with non-admin sandbox".to_string(), description: None, actions: vec![Box::new({ - let otel = self.otel_manager.clone(); + let otel = self.session_telemetry.clone(); let preset = legacy_preset; move |tx| { otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ddcd015c6e..a9819fb188 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -32,8 +32,8 @@ use codex_core::models_manager::collaboration_mode_presets::CollaborationModesCo use codex_core::models_manager::manager::ModelsManager; use codex_core::skills::model::SkillMetadata; use codex_core::terminal::TerminalName; -use codex_otel::OtelManager; use codex_otel::RuntimeMetricsSummary; +use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; @@ -1669,7 +1669,7 @@ async fn helpers_are_available_and_do_not_panic() { let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; let resolved_model = codex_core::test_support::get_model_offline(cfg.model.as_deref()); - let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); + let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let thread_manager = Arc::new( codex_core::test_support::thread_manager_with_models_provider( CodexAuth::from_api_key("test"), @@ -1692,16 +1692,16 @@ async fn helpers_are_available_and_do_not_panic() { model: Some(resolved_model), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), - otel_manager, + session_telemetry, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. let _ = &mut w; } -fn test_otel_manager(config: &Config, model: &str) -> OtelManager { +fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { let model_info = codex_core::test_support::construct_model_info_offline(model, config); - OtelManager::new( + SessionTelemetry::new( ThreadId::new(), model, model_info.slug.as_str(), @@ -1734,7 +1734,7 @@ async fn make_chatwidget_manual( cfg.model = Some(model.to_string()); } let prevent_idle_sleep = cfg.features.enabled(Feature::PreventIdleSleep); - let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); + let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), @@ -1777,7 +1777,7 @@ async fn make_chatwidget_manual( active_collaboration_mask, auth_manager, models_manager, - otel_manager, + session_telemetry, session_header: SessionHeader::new(resolved_model.clone()), initial_user_message: None, token_info: None, @@ -5359,7 +5359,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { .await .expect("config"); let resolved_model = codex_core::test_support::get_model_offline(cfg.model.as_deref()); - let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); + let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let thread_manager = Arc::new( codex_core::test_support::thread_manager_with_models_provider( CodexAuth::from_api_key("test"), @@ -5382,7 +5382,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { model: Some(resolved_model.clone()), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), - otel_manager, + session_telemetry, }; let chat = ChatWidget::new(init, thread_manager); @@ -5409,7 +5409,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { .await .expect("config"); let resolved_model = codex_core::test_support::get_model_offline(cfg.model.as_deref()); - let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); + let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let thread_manager = Arc::new( codex_core::test_support::thread_manager_with_models_provider( CodexAuth::from_api_key("test"), @@ -5432,7 +5432,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { model: Some(resolved_model.clone()), startup_tooltip_override: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), - otel_manager, + session_telemetry, }; let chat = ChatWidget::new(init, thread_manager); From 0243734300a9421ac887906d79252deda9b9667a Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 6 Mar 2026 19:39:35 -0500 Subject: [PATCH 06/13] feat: Add curated plugin marketplace + Metadata Cleanup. (#13712) 1. Add a synced curated plugin marketplace and include it in marketplace discovery. 2. Expose optional plugin.json interface metadata in plugin/list 3. Tighten plugin and marketplace path handling using validated absolute paths. 4. Let manifests override skill, MCP, and app config paths. 5. Restrict plugin enablement/config loading to the user config layer so plugin enablement is at global level --- .../schema/json/ClientRequest.json | 2 +- .../codex_app_server_protocol.schemas.json | 125 ++- .../codex_app_server_protocol.v2.schemas.json | 125 ++- .../schema/json/v2/PluginListParams.json | 2 +- .../schema/json/v2/PluginListResponse.json | 127 ++- .../schema/typescript/v2/PluginInterface.ts | 6 + .../schema/typescript/v2/PluginListParams.ts | 2 +- .../typescript/v2/PluginMarketplaceEntry.ts | 3 +- .../schema/typescript/v2/PluginSource.ts | 3 +- .../schema/typescript/v2/PluginSummary.ts | 3 +- .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 29 +- codex-rs/app-server/README.md | 4 +- .../app-server/src/codex_message_processor.rs | 39 +- codex-rs/app-server/src/message_processor.rs | 19 +- .../tests/suite/v2/plugin_install.rs | 2 +- .../app-server/tests/suite/v2/plugin_list.rs | 166 +++- codex-rs/core/src/plugins/curated_repo.rs | 353 +++++++++ codex-rs/core/src/plugins/manager.rs | 743 +++++++++++++++--- codex-rs/core/src/plugins/manifest.rs | 196 +++++ codex-rs/core/src/plugins/marketplace.rs | 288 +++++-- codex-rs/core/src/plugins/mod.rs | 7 + codex-rs/core/src/plugins/store.rs | 45 +- 23 files changed, 2107 insertions(+), 183 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts create mode 100644 codex-rs/core/src/plugins/curated_repo.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ff25b72a30..02e428f76b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -969,7 +969,7 @@ "PluginListParams": { "properties": { "cwds": { - "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.", + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, 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 0bebb007cb..6ab65596aa 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 @@ -11811,11 +11811,112 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "defaultPrompt": { + "type": [ + "string", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshots" + ], + "type": "object" + }, "PluginListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "cwds": { - "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.", + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -11850,7 +11951,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "plugins": { "items": { @@ -11871,7 +11972,7 @@ { "properties": { "path": { - "type": "string" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": { "enum": [ @@ -11895,6 +11996,22 @@ "enabled": { "type": "boolean" }, + "id": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginInterface" + }, + { + "type": "null" + } + ] + }, "name": { "type": "string" }, @@ -11904,6 +12021,8 @@ }, "required": [ "enabled", + "id", + "installed", "name", "source" ], 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 da67d650c4..2e0eef061f 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 @@ -8468,11 +8468,112 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "defaultPrompt": { + "type": [ + "string", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshots" + ], + "type": "object" + }, "PluginListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "cwds": { - "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.", + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -8507,7 +8608,7 @@ "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "plugins": { "items": { @@ -8528,7 +8629,7 @@ { "properties": { "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -8552,6 +8653,22 @@ "enabled": { "type": "boolean" }, + "id": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, "name": { "type": "string" }, @@ -8561,6 +8678,8 @@ }, "required": [ "enabled", + "id", + "installed", "name", "source" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index a54a30a340..27ea8c4df3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -8,7 +8,7 @@ }, "properties": { "cwds": { - "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces are considered.", + "description": "Optional working directories used to discover repo marketplaces. When omitted, only home-scoped marketplaces and the official curated marketplace are considered.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index d4ac316640..88ccb51037 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -1,13 +1,118 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "defaultPrompt": { + "type": [ + "string", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshots" + ], + "type": "object" + }, "PluginMarketplaceEntry": { "properties": { "name": { "type": "string" }, "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "plugins": { "items": { @@ -28,7 +133,7 @@ { "properties": { "path": { - "type": "string" + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ @@ -52,6 +157,22 @@ "enabled": { "type": "boolean" }, + "id": { + "type": "string" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, "name": { "type": "string" }, @@ -61,6 +182,8 @@ }, "required": [ "enabled", + "id", + "installed", "name", "source" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts new file mode 100644 index 0000000000..f9f016d09e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, defaultPrompt: string | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts index 7f6f4e5e60..078feca20e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -6,6 +6,6 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; export type PluginListParams = { /** * Optional working directories used to discover repo marketplaces. When omitted, - * only home-scoped marketplaces are considered. + * only home-scoped marketplaces and the official curated marketplace are considered. */ cwds?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts index 5fa44a4867..32042d1dc2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginMarketplaceEntry.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { PluginSummary } from "./PluginSummary"; -export type PluginMarketplaceEntry = { name: string, path: string, plugins: Array, }; +export type PluginMarketplaceEntry = { name: string, path: AbsolutePathBuf, plugins: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts index e70243b100..2f342c5ddd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSource.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginSource = { "type": "local", path: string, }; +export type PluginSource = { "type": "local", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index 2f87a1462b..baefe10dd4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -1,6 +1,7 @@ // 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 { PluginInterface } from "./PluginInterface"; import type { PluginSource } from "./PluginSource"; -export type PluginSummary = { name: string, source: PluginSource, enabled: boolean, }; +export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, interface: PluginInterface | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 50e489017a..b2c1b21aa0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -152,6 +152,7 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallResponse } from "./PluginInstallResponse"; +export type { PluginInterface } from "./PluginInterface"; export type { PluginListParams } from "./PluginListParams"; export type { PluginListResponse } from "./PluginListResponse"; export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c65c41d1a7..99d55489bd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2397,7 +2397,7 @@ pub struct SkillsListResponse { #[ts(export_to = "v2/")] pub struct PluginListParams { /// Optional working directories used to discover repo marketplaces. When omitted, - /// only home-scoped marketplaces are considered. + /// only home-scoped marketplaces and the official curated marketplace are considered. #[ts(optional = nullable)] pub cwds: Option>, } @@ -2577,7 +2577,7 @@ pub struct SkillsListEntry { #[ts(export_to = "v2/")] pub struct PluginMarketplaceEntry { pub name: String, - pub path: PathBuf, + pub path: AbsolutePathBuf, pub plugins: Vec, } @@ -2585,9 +2585,32 @@ pub struct PluginMarketplaceEntry { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PluginSummary { + pub id: String, pub name: String, pub source: PluginSource, + pub installed: bool, pub enabled: bool, + pub interface: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInterface { + pub display_name: Option, + pub short_description: Option, + pub long_description: Option, + pub developer_name: Option, + pub category: Option, + pub capabilities: Vec, + pub website_url: Option, + pub privacy_policy_url: Option, + pub terms_of_service_url: Option, + pub default_prompt: Option, + pub brand_color: Option, + pub composer_icon: Option, + pub logo: Option, + pub screenshots: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -2597,7 +2620,7 @@ pub struct PluginSummary { pub enum PluginSource { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - Local { path: PathBuf }, + Local { path: AbsolutePathBuf }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 49d216099c..28fbab856f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -148,13 +148,13 @@ Example with notification opt-out: - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). -- `plugin/list` — list discovered marketplaces reachable from optional `cwds` (unioned into a single list). When `cwds` is omitted, only home-scoped marketplaces are considered. Includes each plugin's current `enabled` state from config (**under development; do not call from production clients yet**). +- `plugin/list` — list discovered plugin marketplaces, including plugin id, installed/enabled state, and optional interface metadata (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**). - `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**). - `app/list` — list available apps. - `skills/config/write` — write user-level skill config by path. -- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplacePath`; on success it returns `appsNeedingAuth` for any plugin-declared apps that still are not accessible in the current ChatGPT auth context (**under development; do not call from production clients yet**). +- `plugin/install` — install a plugin from a discovered marketplace entry and return any apps that still need auth (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7898e2ffbb..523f3bbe98 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -80,6 +80,7 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; @@ -411,6 +412,21 @@ pub(crate) struct CodexMessageProcessorArgs { } impl CodexMessageProcessor { + pub(crate) fn clear_plugin_related_caches(&self) { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); + } + + pub(crate) async fn maybe_start_curated_repo_sync_for_latest_config(&self) { + match self.load_latest_config(None).await { + Ok(config) => self + .thread_manager + .plugins_manager() + .maybe_start_curated_repo_sync_for_config(&config), + Err(err) => warn!("failed to load latest config for curated plugin sync: {err:?}"), + } + } + fn current_account_updated_notification(&self) -> AccountUpdatedNotification { let auth = self.auth_manager.auth_cached(); AccountUpdatedNotification { @@ -5022,6 +5038,8 @@ impl CodexMessageProcessor { .plugins .into_iter() .map(|plugin| PluginSummary { + id: plugin.id, + installed: plugin.installed, enabled: plugin.enabled, name: plugin.name, source: match plugin.source { @@ -5029,6 +5047,22 @@ impl CodexMessageProcessor { PluginSource::Local { path } } }, + interface: plugin.interface.map(|interface| PluginInterface { + display_name: interface.display_name, + short_description: interface.short_description, + long_description: interface.long_description, + developer_name: interface.developer_name, + category: interface.category, + capabilities: interface.capabilities, + website_url: interface.website_url, + privacy_policy_url: interface.privacy_policy_url, + terms_of_service_url: interface.terms_of_service_url, + default_prompt: interface.default_prompt, + brand_color: interface.brand_color, + composer_icon: interface.composer_icon, + logo: interface.logo, + screenshots: interface.screenshots, + }), }) .collect(), }) @@ -5190,7 +5224,7 @@ impl CodexMessageProcessor { self.config.as_ref().clone() } }; - let plugin_apps = load_plugin_apps(&result.installed_path); + let plugin_apps = load_plugin_apps(result.installed_path.as_path()); let apps_needing_auth = if plugin_apps.is_empty() || !config.features.enabled(Feature::Apps) { @@ -5254,8 +5288,7 @@ impl CodexMessageProcessor { ) }; - plugins_manager.clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + self.clear_plugin_related_caches(); self.outgoing .send_response(request_id, PluginInstallResponse { apps_needing_auth }) .await; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index f56e12467f..51049e624a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -194,6 +194,9 @@ impl MessageProcessor { .enabled(codex_core::features::Feature::DefaultModeRequestUserInput), }, )); + thread_manager + .plugins_manager() + .maybe_start_curated_repo_sync_for_config(&config); let cloud_requirements = Arc::new(RwLock::new(cloud_requirements)); let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs { auth_manager, @@ -522,7 +525,13 @@ impl MessageProcessor { params: ConfigValueWriteParams, ) { match self.config_api.write_value(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, + Ok(response) => { + self.codex_message_processor.clear_plugin_related_caches(); + self.codex_message_processor + .maybe_start_curated_repo_sync_for_latest_config() + .await; + self.outgoing.send_response(request_id, response).await; + } Err(error) => self.outgoing.send_error(request_id, error).await, } } @@ -533,7 +542,13 @@ impl MessageProcessor { params: ConfigBatchWriteParams, ) { match self.config_api.batch_write(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, + Ok(response) => { + self.codex_message_processor.clear_plugin_related_caches(); + self.codex_message_processor + .maybe_start_curated_repo_sync_for_latest_config() + .await; + self.outgoing.send_response(request_id, response).await; + } Err(error) => self.outgoing.send_error(request_id, error).await, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 8ffbbe283f..7652ca1ae5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -449,7 +449,7 @@ fn write_plugin_source( plugin_name: &str, app_ids: &[&str], ) -> Result<()> { - let plugin_root = repo_root.join(".agents/plugins").join(plugin_name); + let plugin_root = repo_root.join(plugin_name); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 8435500991..a202dcde99 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -125,11 +125,13 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { } #[tokio::test] -async fn plugin_list_includes_enabled_state_from_config() -> Result<()> { +async fn plugin_list_includes_install_and_enabled_state_from_config() -> Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + write_installed_plugin(&codex_home, "codex-curated", "enabled-plugin")?; + write_installed_plugin(&codex_home, "codex-curated", "disabled-plugin")?; std::fs::write( repo_root.path().join(".agents/plugins/marketplace.json"), r#"{ @@ -148,6 +150,13 @@ async fn plugin_list_includes_enabled_state_from_config() -> Result<()> { "source": "local", "path": "./disabled-plugin" } + }, + { + "name": "uninstalled-plugin", + "source": { + "source": "local", + "path": "./uninstalled-plugin" + } } ] }"#, @@ -185,16 +194,31 @@ enabled = false .marketplaces .into_iter() .find(|marketplace| { - marketplace.path == repo_root.path().join(".agents/plugins/marketplace.json") + marketplace.path + == AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + ) + .expect("absolute marketplace path") }) .expect("expected repo marketplace entry"); assert_eq!(marketplace.name, "codex-curated"); - assert_eq!(marketplace.plugins.len(), 2); + assert_eq!(marketplace.plugins.len(), 3); + assert_eq!(marketplace.plugins[0].id, "enabled-plugin@codex-curated"); assert_eq!(marketplace.plugins[0].name, "enabled-plugin"); + assert_eq!(marketplace.plugins[0].installed, true); assert_eq!(marketplace.plugins[0].enabled, true); + assert_eq!(marketplace.plugins[1].id, "disabled-plugin@codex-curated"); assert_eq!(marketplace.plugins[1].name, "disabled-plugin"); + assert_eq!(marketplace.plugins[1].installed, true); assert_eq!(marketplace.plugins[1].enabled, false); + assert_eq!( + marketplace.plugins[2].id, + "uninstalled-plugin@codex-curated" + ); + assert_eq!(marketplace.plugins[2].name, "uninstalled-plugin"); + assert_eq!(marketplace.plugins[2].installed, false); + assert_eq!(marketplace.plugins[2].enabled, false); Ok(()) } @@ -202,6 +226,7 @@ enabled = false async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> { let codex_home = TempDir::new()?; std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?; + write_installed_plugin(&codex_home, "codex-curated", "shared-plugin")?; std::fs::write( codex_home.path().join(".agents/plugins/marketplace.json"), r#"{ @@ -294,6 +319,141 @@ enabled = false .flat_map(|marketplace| marketplace.plugins.iter()) .find(|plugin| plugin.name == "shared-plugin") .expect("expected shared-plugin entry"); + assert_eq!(shared_plugin.id, "shared-plugin@codex-curated"); + assert_eq!(shared_plugin.installed, true); assert_eq!(shared_plugin.enabled, true); Ok(()) } + +#[tokio::test] +async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "demo-plugin", + "interface": { + "displayName": "Plugin Display Name", + "shortDescription": "Short description for subtitle", + "longDescription": "Long description for details page", + "developerName": "OpenAI", + "category": "Productivity", + "capabilities": ["Interactive", "Write"], + "websiteURL": "https://openai.com/", + "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", + "defaultPrompt": "Starter prompt for trying a plugin", + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": ["./assets/screenshot1.png", "./assets/screenshot2.png"] + } +}"##, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "demo-plugin") + .expect("expected demo-plugin entry"); + + assert_eq!(plugin.id, "demo-plugin@codex-curated"); + assert_eq!(plugin.installed, false); + assert_eq!(plugin.enabled, false); + let interface = plugin + .interface + .as_ref() + .expect("expected plugin interface"); + assert_eq!( + interface.display_name.as_deref(), + Some("Plugin Display Name") + ); + assert_eq!( + interface.website_url.as_deref(), + Some("https://openai.com/") + ); + assert_eq!( + interface.privacy_policy_url.as_deref(), + Some("https://openai.com/policies/row-privacy-policy/") + ); + assert_eq!( + interface.terms_of_service_url.as_deref(), + Some("https://openai.com/policies/row-terms-of-use/") + ); + assert_eq!( + interface.composer_icon, + Some(AbsolutePathBuf::try_from( + plugin_root.join("assets/icon.png") + )?) + ); + assert_eq!( + interface.logo, + Some(AbsolutePathBuf::try_from( + plugin_root.join("assets/logo.png") + )?) + ); + assert_eq!( + interface.screenshots, + vec![ + AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot1.png"))?, + AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot2.png"))?, + ] + ); + Ok(()) +} + +fn write_installed_plugin( + codex_home: &TempDir, + marketplace_name: &str, + plugin_name: &str, +) -> Result<()> { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("local/.codex-plugin"); + std::fs::create_dir_all(&plugin_root)?; + std::fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + Ok(()) +} diff --git a/codex-rs/core/src/plugins/curated_repo.rs b/codex-rs/core/src/plugins/curated_repo.rs new file mode 100644 index 0000000000..3ec492db30 --- /dev/null +++ b/codex-rs/core/src/plugins/curated_repo.rs @@ -0,0 +1,353 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Output; +use std::process::Stdio; +use std::thread; +use std::time::Duration; +use std::time::Instant; + +const OPENAI_PLUGINS_REPO_URL: &str = "https://github.com/openai/plugins.git"; +const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; +const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); + +pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf { + codex_home.join(CURATED_PLUGINS_RELATIVE_DIR) +} + +pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); + let remote_sha = git_ls_remote_head_sha()?; + let local_sha = read_local_sha(&repo_path, &sha_path); + + if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() { + return Ok(()); + } + + let Some(parent) = repo_path.parent() else { + return Err(format!( + "failed to determine curated plugins parent directory for {}", + repo_path.display() + )); + }; + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins parent directory {}: {err}", + parent.display() + ) + })?; + + let clone_dir = tempfile::Builder::new() + .prefix("plugins-clone-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create temporary curated plugins directory in {}: {err}", + parent.display() + ) + })?; + let cloned_repo_path = clone_dir.path().join("repo"); + let clone_output = run_git_command_with_timeout( + Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("clone") + .arg("--depth") + .arg("1") + .arg(OPENAI_PLUGINS_REPO_URL) + .arg(&cloned_repo_path), + "git clone curated plugins repo", + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&clone_output, "git clone curated plugins repo")?; + + let cloned_sha = git_head_sha(&cloned_repo_path)?; + if cloned_sha != remote_sha { + return Err(format!( + "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + )); + } + + if repo_path.exists() { + let backup_dir = tempfile::Builder::new() + .prefix("plugins-backup-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create curated plugins backup directory in {}: {err}", + parent.display() + ) + })?; + let backup_repo_path = backup_dir.path().join("repo"); + + fs::rename(&repo_path, &backup_repo_path).map_err(|err| { + format!( + "failed to move previous curated plugins repo out of the way at {}: {err}", + repo_path.display() + ) + })?; + + if let Err(err) = fs::rename(&cloned_repo_path, &repo_path) { + let rollback_result = fs::rename(&backup_repo_path, &repo_path); + return match rollback_result { + Ok(()) => Err(format!( + "failed to activate new curated plugins repo at {}: {err}", + repo_path.display() + )), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join("repo"); + Err(format!( + "failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}", + repo_path.display(), + backup_path.display() + )) + } + }; + } + } else { + fs::rename(&cloned_repo_path, &repo_path).map_err(|err| { + format!( + "failed to activate curated plugins repo at {}: {err}", + repo_path.display() + ) + })?; + } + + if let Some(parent) = sha_path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins sha directory {}: {err}", + parent.display() + ) + })?; + } + fs::write(&sha_path, format!("{cloned_sha}\n")).map_err(|err| { + format!( + "failed to write curated plugins sha file {}: {err}", + sha_path.display() + ) + })?; + + Ok(()) +} + +fn read_local_sha(repo_path: &Path, sha_path: &Path) -> Option { + if repo_path.join(".git").is_dir() + && let Ok(sha) = git_head_sha(repo_path) + { + return Some(sha); + } + + fs::read_to_string(sha_path) + .ok() + .map(|sha| sha.trim().to_string()) + .filter(|sha| !sha.is_empty()) +} + +fn git_ls_remote_head_sha() -> Result { + let output = run_git_command_with_timeout( + Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("ls-remote") + .arg(OPENAI_PLUGINS_REPO_URL) + .arg("HEAD"), + "git ls-remote curated plugins repo", + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, "git ls-remote curated plugins repo")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(first_line) = stdout.lines().next() else { + return Err("git ls-remote returned empty output for curated plugins repo".to_string()); + }; + let Some((sha, _)) = first_line.split_once('\t') else { + return Err(format!( + "unexpected git ls-remote output for curated plugins repo: {first_line}" + )); + }; + if sha.is_empty() { + return Err("git ls-remote returned empty sha for curated plugins repo".to_string()); + } + Ok(sha.to_string()) +} + +fn git_head_sha(repo_path: &Path) -> Result { + let output = Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .arg("rev-parse") + .arg("HEAD") + .output() + .map_err(|err| { + format!( + "failed to run git rev-parse HEAD in {}: {err}", + repo_path.display() + ) + })?; + ensure_git_success(&output, "git rev-parse HEAD")?; + + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if sha.is_empty() { + return Err(format!( + "git rev-parse HEAD returned empty output in {}", + repo_path.display() + )); + } + Ok(sha) +} + +fn run_git_command_with_timeout( + command: &mut Command, + context: &str, + timeout: Duration, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to run {context}: {err}"))?; + + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + if start.elapsed() >= timeout { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + let _ = child.kill(); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?; + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return if stderr.is_empty() { + Err(format!("{context} timed out after {}s", timeout.as_secs())) + } else { + Err(format!( + "{context} timed out after {}s: {stderr}", + timeout.as_secs() + )) + }; + } + + thread::sleep(Duration::from_millis(100)); + } +} + +fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!("{context} failed with status {}", output.status)) + } else { + Err(format!( + "{context} failed with status {}: {stderr}", + output.status + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { + let tmp = tempdir().expect("tempdir"); + assert_eq!( + curated_plugins_repo_path(tmp.path()), + tmp.path().join(".tmp/plugins") + ); + } + + #[test] + fn read_local_sha_prefers_repo_head_when_available() { + let tmp = tempdir().expect("tempdir"); + let repo_path = tmp.path().join("repo"); + let sha_path = tmp.path().join("plugins.sha"); + + fs::create_dir_all(&repo_path).expect("create repo dir"); + fs::write(&sha_path, "abc123\n").expect("write sha"); + let init_output = Command::new("git") + .arg("init") + .arg(&repo_path) + .output() + .expect("git init should run"); + ensure_git_success(&init_output, "git init").expect("git init should succeed"); + let config_name_output = Command::new("git") + .arg("-C") + .arg(&repo_path) + .arg("config") + .arg("user.name") + .arg("Codex") + .output() + .expect("git config user.name should run"); + ensure_git_success(&config_name_output, "git config user.name") + .expect("git config user.name should succeed"); + let config_email_output = Command::new("git") + .arg("-C") + .arg(&repo_path) + .arg("config") + .arg("user.email") + .arg("codex@example.com") + .output() + .expect("git config user.email should run"); + ensure_git_success(&config_email_output, "git config user.email") + .expect("git config user.email should succeed"); + fs::write(repo_path.join("README.md"), "demo\n").expect("write file"); + let add_output = Command::new("git") + .arg("-C") + .arg(&repo_path) + .arg("add") + .arg(".") + .output() + .expect("git add should run"); + ensure_git_success(&add_output, "git add").expect("git add should succeed"); + let commit_output = Command::new("git") + .arg("-C") + .arg(&repo_path) + .arg("commit") + .arg("-m") + .arg("init") + .output() + .expect("git commit should run"); + ensure_git_success(&commit_output, "git commit").expect("git commit should succeed"); + + let sha = read_local_sha(&repo_path, &sha_path); + assert_eq!(sha, Some(git_head_sha(&repo_path).expect("repo head sha"))); + } + + #[test] + fn read_local_sha_falls_back_to_sha_file() { + let tmp = tempdir().expect("tempdir"); + let repo_path = tmp.path().join("repo"); + let sha_path = tmp.path().join("plugins.sha"); + fs::write(&sha_path, "abc123\n").expect("write sha"); + + let sha = read_local_sha(&repo_path, &sha_path); + assert_eq!(sha.as_deref(), Some("abc123")); + } +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 0938b39ccf..9e425bcfee 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -1,14 +1,19 @@ +use super::PluginManifestPaths; +use super::curated_plugins_repo_path; use super::load_plugin_manifest; +use super::manifest::PluginManifestInterfaceSummary; use super::marketplace::MarketplaceError; use super::marketplace::MarketplacePluginSourceSummary; use super::marketplace::list_marketplaces; use super::marketplace::resolve_marketplace_plugin; use super::plugin_manifest_name; +use super::plugin_manifest_paths; use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; use super::store::PluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; +use super::sync_openai_plugins_repo; use crate::config::Config; use crate::config::ConfigService; use crate::config::ConfigServiceError; @@ -33,11 +38,15 @@ use std::fs; use std::path::Path; use std::path::PathBuf; use std::sync::RwLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use tracing::warn; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; +const DISABLE_CURATED_PLUGIN_SYNC_ENV_VAR: &str = "CODEX_DISABLE_CURATED_PLUGIN_SYNC"; +static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct AppConnectorId(pub String); @@ -51,14 +60,17 @@ pub struct PluginInstallRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfiguredMarketplaceSummary { pub name: String, - pub path: PathBuf, + pub path: AbsolutePathBuf, pub plugins: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfiguredMarketplacePluginSummary { + pub id: String, pub name: String, pub source: MarketplacePluginSourceSummary, + pub interface: Option, + pub installed: bool, pub enabled: bool, } @@ -256,7 +268,7 @@ impl PluginsManager { let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; let store = self.store.clone(); let result = tokio::task::spawn_blocking(move || { - store.install(resolved.source_path.into_path_buf(), resolved.plugin_id) + store.install(resolved.source_path, resolved.plugin_id) }) .await .map_err(PluginInstallError::join)??; @@ -283,13 +295,21 @@ impl PluginsManager { config: &Config, additional_roots: &[AbsolutePathBuf], ) -> Result, MarketplaceError> { + let installed_plugins = configured_plugins_from_stack(&config.config_layer_stack) + .into_keys() + .filter(|plugin_key| { + PluginId::parse(plugin_key) + .ok() + .is_some_and(|plugin_id| self.store.is_installed(&plugin_id)) + }) + .collect::>(); let configured_plugins = self .plugins_for_config(config) .plugins() .iter() .map(|plugin| (plugin.config_name.clone(), plugin.enabled)) .collect::>(); - let marketplaces = list_marketplaces(additional_roots)?; + let marketplaces = list_marketplaces(&self.marketplace_roots(additional_roots))?; let mut seen_plugin_keys = HashSet::new(); Ok(marketplaces @@ -309,12 +329,15 @@ impl PluginsManager { // Enabled state is keyed by `@`, so duplicate // plugin entries from duplicate marketplace files intentionally // resolve to the first discovered source. + id: plugin_key.clone(), + installed: installed_plugins.contains(&plugin_key), enabled: configured_plugins .get(&plugin_key) .copied() .unwrap_or(false), name: plugin.name, source: plugin.source, + interface: plugin.interface, }) }) .collect::>(); @@ -327,6 +350,49 @@ impl PluginsManager { }) .collect()) } + + pub fn maybe_start_curated_repo_sync_for_config(&self, config: &Config) { + if plugins_feature_enabled_from_stack(&config.config_layer_stack) { + self.start_curated_repo_sync(); + } + } + + pub fn start_curated_repo_sync(&self) { + if std::env::var_os(DISABLE_CURATED_PLUGIN_SYNC_ENV_VAR).is_some() { + return; + } + if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { + return; + } + let codex_home = self.codex_home.clone(); + if let Err(err) = std::thread::Builder::new() + .name("plugins-curated-repo-sync".to_string()) + .spawn(move || { + if let Err(err) = sync_openai_plugins_repo(codex_home.as_path()) { + CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); + warn!("failed to sync curated plugins repo: {err}"); + } + }) + { + CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); + warn!("failed to start curated plugins repo sync task: {err}"); + } + } + + fn marketplace_roots(&self, additional_roots: &[AbsolutePathBuf]) -> Vec { + // Treat the curated catalog as an extra marketplace root so plugin listing can surface it + // without requiring every caller to know where it is stored. + let mut roots = additional_roots.to_vec(); + let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path()); + if curated_repo_root.is_dir() + && let Ok(curated_repo_root) = AbsolutePathBuf::try_from(curated_repo_root) + { + roots.push(curated_repo_root); + } + roots.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path())); + roots.dedup(); + roots + } } #[derive(Debug, thiserror::Error)] @@ -363,8 +429,12 @@ impl PluginInstallError { } fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool { - let effective_config = config_layer_stack.effective_config(); - let Ok(config_toml) = effective_config.try_into::() else { + // Plugins are intentionally opt-in from the persisted user config only. Project config + // layers should not be able to enable plugin loading for a checkout. + let Some(user_layer) = config_layer_stack.get_user_layer() else { + return false; + }; + let Ok(config_toml) = user_layer.config.clone().try_into::() else { warn!("failed to deserialize config when checking plugin feature flag"); return false; }; @@ -455,8 +525,11 @@ pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option { fn configured_plugins_from_stack( config_layer_stack: &ConfigLayerStack, ) -> HashMap { - let effective_config = config_layer_stack.effective_config(); - let Some(plugins_value) = effective_config.get("plugins") else { + // Keep plugin entries aligned with the same user-layer-only semantics as the feature gate. + let Some(user_layer) = config_layer_stack.get_user_layer() else { + return HashMap::new(); + }; + let Some(plugins_value) = user_layer.config.get("plugins") else { return HashMap::new(); }; match plugins_value.clone().try_into() { @@ -510,11 +583,12 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) return loaded_plugin; }; + let manifest_paths = plugin_manifest_paths(&manifest, plugin_root.as_path()); loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path())); loaded_plugin.manifest_description = manifest.description; - loaded_plugin.skill_roots = default_skill_roots(plugin_root.as_path()); + loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), &manifest_paths); let mut mcp_servers = HashMap::new(); - for mcp_config_path in default_mcp_config_paths(plugin_root.as_path()) { + for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), &manifest_paths) { let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path); for (name, config) in plugin_mcp.mcp_servers { if mcp_servers.insert(name.clone(), config).is_some() { @@ -532,6 +606,16 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) loaded_plugin } +fn plugin_skill_roots(plugin_root: &Path, manifest_paths: &PluginManifestPaths) -> Vec { + let mut paths = default_skill_roots(plugin_root); + if let Some(path) = &manifest_paths.skills { + paths.push(path.to_path_buf()); + } + paths.sort_unstable(); + paths.dedup(); + paths +} + fn default_skill_roots(plugin_root: &Path) -> Vec { let skills_dir = plugin_root.join(DEFAULT_SKILLS_DIR_NAME); if skills_dir.is_dir() { @@ -541,42 +625,87 @@ fn default_skill_roots(plugin_root: &Path) -> Vec { } } -fn default_mcp_config_paths(plugin_root: &Path) -> Vec { +fn plugin_mcp_config_paths( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, +) -> Vec { + if let Some(path) = &manifest_paths.mcp_servers { + return vec![path.clone()]; + } + default_mcp_config_paths(plugin_root) +} + +fn default_mcp_config_paths(plugin_root: &Path) -> Vec { let mut paths = Vec::new(); let default_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE); - if default_path.is_file() { + if default_path.is_file() + && let Ok(default_path) = AbsolutePathBuf::try_from(default_path) + { paths.push(default_path); } - paths.sort_unstable(); - paths.dedup(); + paths.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path())); + paths.dedup_by(|left, right| left.as_path() == right.as_path()); paths } pub fn load_plugin_apps(plugin_root: &Path) -> Vec { - load_apps_from_file(plugin_root, &plugin_root.join(DEFAULT_APP_CONFIG_FILE)) + if let Some(manifest) = load_plugin_manifest(plugin_root) { + let manifest_paths = plugin_manifest_paths(&manifest, plugin_root); + return load_apps_from_paths( + plugin_root, + plugin_app_config_paths(plugin_root, &manifest_paths), + ); + } + load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)) } -fn load_apps_from_file(plugin_root: &Path, app_config_path: &Path) -> Vec { - let Ok(contents) = fs::read_to_string(app_config_path) else { - return Vec::new(); - }; - let parsed = match serde_json::from_str::(&contents) { - Ok(parsed) => parsed, - Err(err) => { - warn!( - path = %app_config_path.display(), - "failed to parse plugin app config: {err}" - ); - return Vec::new(); - } - }; +fn plugin_app_config_paths( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, +) -> Vec { + if let Some(path) = &manifest_paths.apps { + return vec![path.clone()]; + } + default_app_config_paths(plugin_root) +} - let mut apps: Vec = parsed.apps.into_values().collect(); - apps.sort_unstable_by(|left, right| left.id.cmp(&right.id)); +fn default_app_config_paths(plugin_root: &Path) -> Vec { + let mut paths = Vec::new(); + let default_path = plugin_root.join(DEFAULT_APP_CONFIG_FILE); + if default_path.is_file() + && let Ok(default_path) = AbsolutePathBuf::try_from(default_path) + { + paths.push(default_path); + } + paths.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path())); + paths.dedup_by(|left, right| left.as_path() == right.as_path()); + paths +} - let mut connector_ids: Vec = apps - .into_iter() - .filter_map(|app| { +fn load_apps_from_paths( + plugin_root: &Path, + app_config_paths: Vec, +) -> Vec { + let mut connector_ids = Vec::new(); + for app_config_path in app_config_paths { + let Ok(contents) = fs::read_to_string(app_config_path.as_path()) else { + continue; + }; + let parsed = match serde_json::from_str::(&contents) { + Ok(parsed) => parsed, + Err(err) => { + warn!( + path = %app_config_path.display(), + "failed to parse plugin app config: {err}" + ); + continue; + } + }; + + let mut apps: Vec = parsed.apps.into_values().collect(); + apps.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + + connector_ids.extend(apps.into_iter().filter_map(|app| { if app.id.trim().is_empty() { warn!( plugin = %plugin_root.display(), @@ -586,14 +715,17 @@ fn load_apps_from_file(plugin_root: &Path, app_config_path: &Path) -> Vec PluginMcpDiscovery { - let Ok(contents) = fs::read_to_string(mcp_config_path) else { +fn load_mcp_servers_from_file( + plugin_root: &Path, + mcp_config_path: &AbsolutePathBuf, +) -> PluginMcpDiscovery { + let Ok(contents) = fs::read_to_string(mcp_config_path.as_path()) else { return PluginMcpDiscovery::default(); }; let parsed = match serde_json::from_str::(&contents) { @@ -694,6 +826,11 @@ mod tests { use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::types::McpServerTransportConfig; + use crate::config_loader::ConfigLayerEntry; + use crate::config_loader::ConfigLayerStack; + use crate::config_loader::ConfigRequirements; + use crate::config_loader::ConfigRequirementsToml; + use codex_app_server_protocol::ConfigLayerSource; use pretty_assertions::assert_eq; use std::fs; use tempfile::TempDir; @@ -737,18 +874,34 @@ mod tests { toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") } - async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome { + fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome { write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml); - let config = ConfigBuilder::default() - .codex_home(codex_home.to_path_buf()) - .build() - .await - .expect("config should load"); - PluginsManager::new(codex_home.to_path_buf()).plugins_for_config(&config) + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { + file: AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE)).unwrap(), + }, + toml::from_str(config_toml).expect("plugin test config should parse"), + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should build"); + PluginsManager::new(codex_home.to_path_buf()) + .plugins_for_layer_stack(codex_home, &stack, false) } - #[tokio::test] - async fn load_plugins_loads_default_skills_and_mcp_servers() { + async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config { + ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .fallback_cwd(Some(cwd.to_path_buf())) + .build() + .await + .expect("config should load") + } + + #[test] + fn load_plugins_loads_default_skills_and_mcp_servers() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -792,8 +945,7 @@ mod tests { }"#, ); - let outcome = - load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()).await; + let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( outcome.plugins, @@ -854,8 +1006,217 @@ mod tests { ); } - #[tokio::test] - async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { + #[test] + fn load_plugins_uses_manifest_configured_component_paths() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "skills": "./custom-skills/", + "mcpServers": "./config/custom.mcp.json", + "apps": "./config/custom.app.json" +}"#, + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "default": { + "type": "http", + "url": "https://default.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ + "mcpServers": { + "custom": { + "type": "http", + "url": "https://custom.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "default": { + "id": "connector_default" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ + "apps": { + "custom": { + "id": "connector_custom" + } + } +}"#, + ); + + let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); + + assert_eq!( + outcome.plugins[0].skill_roots, + vec![ + plugin_root.join("custom-skills"), + plugin_root.join("skills") + ] + ); + assert_eq!( + outcome.plugins[0].mcp_servers, + HashMap::from([( + "custom".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://custom.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]) + ); + assert_eq!( + outcome.plugins[0].apps, + vec![AppConnectorId("connector_custom".to_string())] + ); + } + + #[test] + fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "skills": "custom-skills", + "mcpServers": "config/custom.mcp.json", + "apps": "config/custom.app.json" +}"#, + ); + write_file( + &plugin_root.join("skills/default-skill/SKILL.md"), + "---\nname: default-skill\ndescription: default skill\n---\n", + ); + write_file( + &plugin_root.join("custom-skills/custom-skill/SKILL.md"), + "---\nname: custom-skill\ndescription: custom skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "default": { + "type": "http", + "url": "https://default.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.mcp.json"), + r#"{ + "mcpServers": { + "custom": { + "type": "http", + "url": "https://custom.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "default": { + "id": "connector_default" + } + } +}"#, + ); + write_file( + &plugin_root.join("config/custom.app.json"), + r#"{ + "apps": { + "custom": { + "id": "connector_custom" + } + } +}"#, + ); + + let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); + + assert_eq!( + outcome.plugins[0].skill_roots, + vec![plugin_root.join("skills")] + ); + assert_eq!( + outcome.plugins[0].mcp_servers, + HashMap::from([( + "default".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://default.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + }, + )]) + ); + assert_eq!( + outcome.plugins[0].apps, + vec![AppConnectorId("connector_default".to_string())] + ); + } + + #[test] + fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -878,8 +1239,7 @@ mod tests { }"#, ); - let outcome = - load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path()).await; + let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path()); assert_eq!( outcome.plugins, @@ -899,8 +1259,8 @@ mod tests { assert!(outcome.effective_mcp_servers().is_empty()); } - #[tokio::test] - async fn effective_apps_dedupes_connector_ids_across_plugins() { + #[test] + fn effective_apps_dedupes_connector_ids_across_plugins() { let codex_home = TempDir::new().unwrap(); let plugin_a_root = codex_home .path() @@ -962,7 +1322,7 @@ mod tests { let config_toml = toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"); - let outcome = load_plugins_from_config(&config_toml, codex_home.path()).await; + let outcome = load_plugins_from_config(&config_toml, codex_home.path()); assert_eq!( outcome.effective_apps(), @@ -1082,8 +1442,8 @@ mod tests { ); } - #[tokio::test] - async fn load_plugins_returns_empty_when_feature_disabled() { + #[test] + fn load_plugins_returns_empty_when_feature_disabled() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -1099,14 +1459,13 @@ mod tests { "---\nname: sample-search\ndescription: search sample data\n---\n", ); - let outcome = - load_plugins_from_config(&plugin_config_toml(true, false), codex_home.path()).await; + let outcome = load_plugins_from_config(&plugin_config_toml(true, false), codex_home.path()); assert_eq!(outcome, PluginLoadOutcome::default()); } - #[tokio::test] - async fn load_plugins_rejects_invalid_plugin_keys() { + #[test] + fn load_plugins_rejects_invalid_plugin_keys() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -1133,8 +1492,7 @@ mod tests { let outcome = load_plugins_from_config( &toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"), codex_home.path(), - ) - .await; + ); assert_eq!(outcome.plugins.len(), 1); assert_eq!( @@ -1151,11 +1509,7 @@ mod tests { let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); - write_plugin( - &repo_root.join(".agents/plugins"), - "sample-plugin", - "sample-plugin", - ); + write_plugin(&repo_root, "sample-plugin", "sample-plugin"); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ @@ -1190,7 +1544,7 @@ mod tests { PluginInstallResult { plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(), plugin_version: "local".to_string(), - installed_path, + installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(), } ); @@ -1200,11 +1554,21 @@ mod tests { } #[tokio::test] - async fn list_marketplaces_for_config_includes_enabled_state() { + async fn list_marketplaces_includes_enabled_state() { let tmp = tempfile::tempdir().unwrap(); let repo_root = tmp.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).unwrap(); fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "enabled-plugin/local", + "enabled-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "disabled-plugin/local", + "disabled-plugin", + ); fs::write( repo_root.join(".agents/plugins/marketplace.json"), r#"{ @@ -1241,12 +1605,7 @@ enabled = false "#, ); - let config = ConfigBuilder::default() - .codex_home(tmp.path().to_path_buf()) - .build() - .await - .expect("config should load"); - + let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) .unwrap(); @@ -1254,7 +1613,11 @@ enabled = false let marketplace = marketplaces .into_iter() .find(|marketplace| { - marketplace.path == tmp.path().join("repo/.agents/plugins/marketplace.json") + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap() }) .expect("expected repo marketplace entry"); @@ -1262,20 +1625,33 @@ enabled = false marketplace, ConfiguredMarketplaceSummary { name: "debug".to_string(), - path: tmp.path().join("repo/.agents/plugins/marketplace.json"), + path: AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap(), plugins: vec![ ConfiguredMarketplacePluginSummary { + id: "enabled-plugin@debug".to_string(), name: "enabled-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: tmp.path().join("repo/.agents/plugins/enabled-plugin"), + path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) + .unwrap(), }, + interface: None, + installed: true, enabled: true, }, ConfiguredMarketplacePluginSummary { + id: "disabled-plugin@debug".to_string(), name: "disabled-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: tmp.path().join("repo/.agents/plugins/disabled-plugin"), + path: AbsolutePathBuf::try_from( + tmp.path().join("repo/disabled-plugin"), + ) + .unwrap(), }, + interface: None, + installed: true, enabled: false, }, ], @@ -1284,7 +1660,71 @@ enabled = false } #[tokio::test] - async fn list_marketplaces_for_config_uses_first_duplicate_plugin_entry() { + async fn list_marketplaces_includes_curated_repo_marketplace() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + let plugin_root = curated_root.join("plugins/linear"); + + fs::create_dir_all(curated_root.join(".git")).unwrap(); + fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "linear", + "source": { + "source": "local", + "path": "./plugins/linear" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .unwrap(); + + let config = load_config(tmp.path(), tmp.path()).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[]) + .unwrap(); + + let curated_marketplace = marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "openai-curated") + .expect("curated marketplace should be listed"); + + assert_eq!( + curated_marketplace, + ConfiguredMarketplaceSummary { + name: "openai-curated".to_string(), + path: AbsolutePathBuf::try_from( + curated_root.join(".agents/plugins/marketplace.json") + ) + .unwrap(), + plugins: vec![ConfiguredMarketplacePluginSummary { + id: "linear@openai-curated".to_string(), + name: "linear".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")) + .unwrap(), + }, + interface: None, + installed: false, + enabled: false, + }], + } + ); + } + + #[tokio::test] + async fn list_marketplaces_uses_first_duplicate_plugin_entry() { let tmp = tempfile::tempdir().unwrap(); let repo_a_root = tmp.path().join("repo-a"); let repo_b_root = tmp.path().join("repo-b"); @@ -1344,12 +1784,7 @@ enabled = false "#, ); - let config = ConfigBuilder::default() - .codex_home(tmp.path().to_path_buf()) - .build() - .await - .expect("config should load"); - + let config = load_config(tmp.path(), &repo_a_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config( &config, @@ -1363,16 +1798,23 @@ enabled = false let repo_a_marketplace = marketplaces .iter() .find(|marketplace| { - marketplace.path == tmp.path().join("repo-a/.agents/plugins/marketplace.json") + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo-a/.agents/plugins/marketplace.json"), + ) + .unwrap() }) .expect("repo-a marketplace should be listed"); assert_eq!( repo_a_marketplace.plugins, vec![ConfiguredMarketplacePluginSummary { + id: "dup-plugin@debug".to_string(), name: "dup-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: tmp.path().join("repo-a/.agents/plugins/from-a"), + path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), }, + interface: None, + installed: false, enabled: true, }] ); @@ -1380,16 +1822,23 @@ enabled = false let repo_b_marketplace = marketplaces .iter() .find(|marketplace| { - marketplace.path == tmp.path().join("repo-b/.agents/plugins/marketplace.json") + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo-b/.agents/plugins/marketplace.json"), + ) + .unwrap() }) .expect("repo-b marketplace should be listed"); assert_eq!( repo_b_marketplace.plugins, vec![ConfiguredMarketplacePluginSummary { + id: "b-only-plugin@debug".to_string(), name: "b-only-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: tmp.path().join("repo-b/.agents/plugins/from-b-only"), + path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), }, + interface: None, + installed: false, enabled: false, }] ); @@ -1401,4 +1850,116 @@ enabled = false .count(); assert_eq!(duplicate_plugin_count, 1); } + + #[tokio::test] + async fn list_marketplaces_marks_configured_plugin_uninstalled_when_cache_is_missing() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + } + } + ] +}"#, + ) + .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."sample-plugin@debug"] +enabled = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap(); + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| { + marketplace.path + == AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap() + }) + .expect("expected repo marketplace entry"); + + assert_eq!( + marketplace, + ConfiguredMarketplaceSummary { + name: "debug".to_string(), + path: AbsolutePathBuf::try_from( + tmp.path().join("repo/.agents/plugins/marketplace.json"), + ) + .unwrap(), + plugins: vec![ConfiguredMarketplacePluginSummary { + id: "sample-plugin@debug".to_string(), + name: "sample-plugin".to_string(), + source: MarketplacePluginSourceSummary::Local { + path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")) + .unwrap(), + }, + interface: None, + installed: false, + enabled: true, + }], + } + ); + } + + #[test] + fn load_plugins_ignores_project_config_files() { + let codex_home = TempDir::new().unwrap(); + let project_root = codex_home.path().join("project"); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &project_root.join(".codex/config.toml"), + &plugin_config_toml(true, true), + ); + + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::try_from(project_root.join(".codex")) + .unwrap(), + }, + toml::from_str(&plugin_config_toml(true, true)) + .expect("project config should parse"), + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should build"); + + let outcome = PluginsManager::new(codex_home.path().to_path_buf()).plugins_for_layer_stack( + &project_root, + &stack, + false, + ); + + assert_eq!(outcome, PluginLoadOutcome::default()); + } } diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index 755db5d501..ae43fd015a 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -1,13 +1,89 @@ +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use std::fs; +use std::path::Component; use std::path::Path; pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; #[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] pub(crate) struct PluginManifest { + #[serde(default)] pub(crate) name: String, + #[serde(default)] pub(crate) description: Option, + // Keep manifest paths as raw strings so we can validate the required `./...` syntax before + // resolving them under the plugin root. + #[serde(default)] + skills: Option, + #[serde(default)] + mcp_servers: Option, + #[serde(default)] + apps: Option, + #[serde(default)] + interface: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginManifestPaths { + pub skills: Option, + pub mcp_servers: Option, + pub apps: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginManifestInterfaceSummary { + pub display_name: Option, + pub short_description: Option, + pub long_description: Option, + pub developer_name: Option, + pub category: Option, + pub capabilities: Vec, + pub website_url: Option, + pub privacy_policy_url: Option, + pub terms_of_service_url: Option, + pub default_prompt: Option, + pub brand_color: Option, + pub composer_icon: Option, + pub logo: Option, + pub screenshots: Vec, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginManifestInterface { + #[serde(default)] + display_name: Option, + #[serde(default)] + short_description: Option, + #[serde(default)] + long_description: Option, + #[serde(default)] + developer_name: Option, + #[serde(default)] + category: Option, + #[serde(default)] + capabilities: Vec, + #[serde(default)] + #[serde(alias = "websiteURL")] + website_url: Option, + #[serde(default)] + #[serde(alias = "privacyPolicyURL")] + privacy_policy_url: Option, + #[serde(default)] + #[serde(alias = "termsOfServiceURL")] + terms_of_service_url: Option, + #[serde(default)] + default_prompt: Option, + #[serde(default)] + brand_color: Option, + #[serde(default)] + composer_icon: Option, + #[serde(default)] + logo: Option, + #[serde(default)] + screenshots: Vec, } pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option { @@ -36,3 +112,123 @@ pub(crate) fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path .unwrap_or(&manifest.name) .to_string() } + +pub(crate) fn plugin_manifest_interface( + manifest: &PluginManifest, + plugin_root: &Path, +) -> Option { + let interface = manifest.interface.as_ref()?; + let interface = PluginManifestInterfaceSummary { + display_name: interface.display_name.clone(), + short_description: interface.short_description.clone(), + long_description: interface.long_description.clone(), + developer_name: interface.developer_name.clone(), + category: interface.category.clone(), + capabilities: interface.capabilities.clone(), + website_url: interface.website_url.clone(), + privacy_policy_url: interface.privacy_policy_url.clone(), + terms_of_service_url: interface.terms_of_service_url.clone(), + default_prompt: interface.default_prompt.clone(), + brand_color: interface.brand_color.clone(), + composer_icon: resolve_interface_asset_path( + plugin_root, + "interface.composerIcon", + interface.composer_icon.as_deref(), + ), + logo: resolve_interface_asset_path( + plugin_root, + "interface.logo", + interface.logo.as_deref(), + ), + screenshots: interface + .screenshots + .iter() + .filter_map(|screenshot| { + resolve_interface_asset_path(plugin_root, "interface.screenshots", Some(screenshot)) + }) + .collect(), + }; + + let has_fields = interface.display_name.is_some() + || interface.short_description.is_some() + || interface.long_description.is_some() + || interface.developer_name.is_some() + || interface.category.is_some() + || !interface.capabilities.is_empty() + || interface.website_url.is_some() + || interface.privacy_policy_url.is_some() + || interface.terms_of_service_url.is_some() + || interface.default_prompt.is_some() + || interface.brand_color.is_some() + || interface.composer_icon.is_some() + || interface.logo.is_some() + || !interface.screenshots.is_empty(); + + has_fields.then_some(interface) +} + +pub(crate) fn plugin_manifest_paths( + manifest: &PluginManifest, + plugin_root: &Path, +) -> PluginManifestPaths { + PluginManifestPaths { + skills: resolve_manifest_path(plugin_root, "skills", manifest.skills.as_deref()), + mcp_servers: resolve_manifest_path( + plugin_root, + "mcpServers", + manifest.mcp_servers.as_deref(), + ), + apps: resolve_manifest_path(plugin_root, "apps", manifest.apps.as_deref()), + } +} + +fn resolve_interface_asset_path( + plugin_root: &Path, + field: &'static str, + path: Option<&str>, +) -> Option { + resolve_manifest_path(plugin_root, field, path) +} + +fn resolve_manifest_path( + plugin_root: &Path, + field: &'static str, + path: Option<&str>, +) -> Option { + // `plugin.json` paths are required to be relative to the plugin root and we return the + // normalized absolute path to the rest of the system. + let path = path?; + if path.is_empty() { + return None; + } + let Some(relative_path) = path.strip_prefix("./") else { + tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root"); + return None; + }; + if relative_path.is_empty() { + tracing::warn!("ignoring {field}: path must not be `./`"); + return None; + } + + let mut normalized = std::path::PathBuf::new(); + for component in Path::new(relative_path).components() { + match component { + Component::Normal(component) => normalized.push(component), + Component::ParentDir => { + tracing::warn!("ignoring {field}: path must not contain '..'"); + return None; + } + _ => { + tracing::warn!("ignoring {field}: path must stay within the plugin root"); + return None; + } + } + } + + AbsolutePathBuf::try_from(plugin_root.join(normalized)) + .map_err(|err| { + tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}"); + err + }) + .ok() +} diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index 56e8b15dd7..f348ce429b 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -1,3 +1,6 @@ +use super::PluginManifestInterfaceSummary; +use super::load_plugin_manifest; +use super::plugin_manifest_interface; use super::store::PluginId; use super::store::PluginIdError; use crate::git_info::get_git_repo_root; @@ -21,7 +24,7 @@ pub struct ResolvedMarketplacePlugin { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplaceSummary { pub name: String, - pub path: PathBuf, + pub path: AbsolutePathBuf, pub plugins: Vec, } @@ -29,11 +32,12 @@ pub struct MarketplaceSummary { pub struct MarketplacePluginSummary { pub name: String, pub source: MarketplacePluginSourceSummary, + pub interface: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum MarketplacePluginSourceSummary { - Local { path: PathBuf }, + Local { path: AbsolutePathBuf }, } #[derive(Debug, thiserror::Error)] @@ -73,7 +77,7 @@ pub fn resolve_marketplace_plugin( marketplace_path: &AbsolutePathBuf, plugin_name: &str, ) -> Result { - let marketplace = load_marketplace(marketplace_path.as_path())?; + let marketplace = load_marketplace(marketplace_path)?; let marketplace_name = marketplace.name; let plugin = marketplace .plugins @@ -92,7 +96,7 @@ pub fn resolve_marketplace_plugin( })?; Ok(ResolvedMarketplacePlugin { plugin_id, - source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?, + source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?, }) } @@ -109,23 +113,21 @@ fn list_marketplaces_with_home( let mut marketplaces = Vec::new(); for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) { - let marketplace = load_marketplace(marketplace_path.as_path())?; + let marketplace = load_marketplace(&marketplace_path)?; let mut plugins = Vec::new(); for plugin in marketplace.plugins { - let source = match plugin.source { - MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local { - path: resolve_plugin_source_path( - marketplace_path.as_path(), - MarketplacePluginSource::Local { path }, - )? - .into_path_buf(), - }, + let source_path = resolve_plugin_source_path(&marketplace_path, plugin.source)?; + let source = MarketplacePluginSourceSummary::Local { + path: source_path.clone(), }; + let interface = load_plugin_manifest(source_path.as_path()) + .and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path())); plugins.push(MarketplacePluginSummary { name: plugin.name, source, + interface, }); } @@ -142,30 +144,34 @@ fn list_marketplaces_with_home( fn discover_marketplace_paths_from_roots( additional_roots: &[AbsolutePathBuf], home_dir: Option<&Path>, -) -> Vec { +) -> Vec { let mut paths = Vec::new(); if let Some(home) = home_dir { let path = home.join(MARKETPLACE_RELATIVE_PATH); - if path.is_file() { + if path.is_file() + && let Ok(path) = AbsolutePathBuf::try_from(path) + { paths.push(path); } } for root in additional_roots { - if let Some(repo_root) = get_git_repo_root(root.as_path()) { - let path = repo_root.join(MARKETPLACE_RELATIVE_PATH); - if path.is_file() && !paths.contains(&path) { - paths.push(path); - } + if let Some(repo_root) = get_git_repo_root(root.as_path()) + && let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root) + && let Ok(path) = repo_root.join(MARKETPLACE_RELATIVE_PATH) + && path.as_path().is_file() + && !paths.contains(&path) + { + paths.push(path); } } paths } -fn load_marketplace(path: &Path) -> Result { - let contents = fs::read_to_string(path).map_err(|err| { +fn load_marketplace(path: &AbsolutePathBuf) -> Result { + let contents = fs::read_to_string(path.as_path()).map_err(|err| { if err.kind() == io::ErrorKind::NotFound { MarketplaceError::MarketplaceNotFound { path: path.to_path_buf(), @@ -181,7 +187,7 @@ fn load_marketplace(path: &Path) -> Result { } fn resolve_plugin_source_path( - marketplace_path: &Path, + marketplace_path: &AbsolutePathBuf, source: MarketplacePluginSource, ) -> Result { match source { @@ -206,25 +212,61 @@ fn resolve_plugin_source_path( { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), - message: "local plugin source path must stay within the marketplace directory" + message: "local plugin source path must stay within the marketplace root" .to_string(), }); } - let source_path = marketplace_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(relative_source_path); - AbsolutePathBuf::try_from(source_path).map_err(|err| { - MarketplaceError::InvalidMarketplaceFile { + // `marketplace.json` lives under `/.agents/plugins/`, but local plugin paths + // are resolved relative to ``, not relative to the `plugins/` directory. + marketplace_root_dir(marketplace_path)? + .join(relative_source_path) + .map_err(|err| MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!("plugin source path must resolve to an absolute path: {err}"), - } - }) + }) } } } +fn marketplace_root_dir( + marketplace_path: &AbsolutePathBuf, +) -> Result { + let Some(plugins_dir) = marketplace_path.parent() else { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "marketplace file must live under `/.agents/plugins/`".to_string(), + }); + }; + let Some(dot_agents_dir) = plugins_dir.parent() else { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "marketplace file must live under `/.agents/plugins/`".to_string(), + }); + }; + let Some(marketplace_root) = dot_agents_dir.parent() else { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "marketplace file must live under `/.agents/plugins/`".to_string(), + }); + }; + + if plugins_dir.as_path().file_name().and_then(|s| s.to_str()) != Some("plugins") + || dot_agents_dir + .as_path() + .file_name() + .and_then(|s| s.to_str()) + != Some(".agents") + { + return Err(MarketplaceError::InvalidMarketplaceFile { + path: marketplace_path.to_path_buf(), + message: "marketplace file must live under `/.agents/plugins/`".to_string(), + }); + } + + Ok(marketplace_root) +} + #[derive(Debug, Deserialize)] struct MarketplaceFile { name: String, @@ -284,8 +326,7 @@ mod tests { ResolvedMarketplacePlugin { plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string()) .unwrap(), - source_path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/plugin-1")) - .unwrap(), + source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(), } ); } @@ -381,37 +422,51 @@ mod tests { vec![ MarketplaceSummary { name: "codex-curated".to_string(), - path: home_root.join(".agents/plugins/marketplace.json"), + path: AbsolutePathBuf::try_from( + home_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), plugins: vec![ MarketplacePluginSummary { name: "shared-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: home_root.join(".agents/plugins/home-shared"), + path: AbsolutePathBuf::try_from(home_root.join("home-shared")) + .unwrap(), }, + interface: None, }, MarketplacePluginSummary { name: "home-only".to_string(), source: MarketplacePluginSourceSummary::Local { - path: home_root.join(".agents/plugins/home-only"), + path: AbsolutePathBuf::try_from(home_root.join("home-only")) + .unwrap(), }, + interface: None, }, ], }, MarketplaceSummary { name: "codex-curated".to_string(), - path: repo_root.join(".agents/plugins/marketplace.json"), + path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), plugins: vec![ MarketplacePluginSummary { name: "shared-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: repo_root.join(".agents/plugins/repo-shared"), + path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")) + .unwrap(), }, + interface: None, }, MarketplacePluginSummary { name: "repo-only".to_string(), source: MarketplacePluginSourceSummary::Local { - path: repo_root.join(".agents/plugins/repo-only"), + path: AbsolutePathBuf::try_from(repo_root.join("repo-only")) + .unwrap(), }, + interface: None, }, ], }, @@ -475,22 +530,24 @@ mod tests { vec![ MarketplaceSummary { name: "codex-curated".to_string(), - path: home_marketplace, + path: AbsolutePathBuf::try_from(home_marketplace).unwrap(), plugins: vec![MarketplacePluginSummary { name: "local-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: home_root.join(".agents/plugins/home-plugin"), + path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(), }, + interface: None, }], }, MarketplaceSummary { name: "codex-curated".to_string(), - path: repo_marketplace.clone(), + path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(), plugins: vec![MarketplacePluginSummary { name: "local-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: repo_root.join(".agents/plugins/repo-plugin"), + path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(), }, + interface: None, }], }, ] @@ -504,7 +561,7 @@ mod tests { assert_eq!( resolved.source_path, - AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap() + AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap() ); } @@ -547,17 +604,152 @@ mod tests { marketplaces, vec![MarketplaceSummary { name: "codex-curated".to_string(), - path: repo_root.join(".agents/plugins/marketplace.json"), + path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(), plugins: vec![MarketplacePluginSummary { name: "local-plugin".to_string(), source: MarketplacePluginSourceSummary::Local { - path: repo_root.join(".agents/plugins/plugin"), + path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(), }, + interface: None, }], }] ); } + #[test] + fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/demo-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "interface": { + "displayName": "Demo", + "capabilities": ["Interactive", "Write"], + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": ["./assets/shot1.png"] + } +}"#, + ) + .unwrap(); + + let marketplaces = + list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) + .unwrap(); + + assert_eq!( + marketplaces[0].plugins[0].interface, + Some(PluginManifestInterfaceSummary { + display_name: Some("Demo".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: vec!["Interactive".to_string(), "Write".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some( + AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(), + ), + logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + screenshots: vec![ + AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(), + ], + }) + ); + } + + #[test] + fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("plugins/demo-plugin"); + + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + ) + .unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "interface": { + "displayName": "Demo", + "capabilities": ["Interactive"], + "composerIcon": "assets/icon.png", + "logo": "/tmp/logo.png", + "screenshots": ["assets/shot1.png"] + } +}"#, + ) + .unwrap(); + + let marketplaces = + list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) + .unwrap(); + + assert_eq!( + marketplaces[0].plugins[0].interface, + Some(PluginManifestInterfaceSummary { + display_name: Some("Demo".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: None, + capabilities: vec!["Interactive".to_string()], + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: None, + logo: None, + screenshots: Vec::new(), + }) + ); + } + #[test] fn resolve_marketplace_plugin_rejects_non_relative_local_paths() { let tmp = tempdir().unwrap(); @@ -634,7 +826,7 @@ mod tests { assert_eq!( resolved.source_path, - AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap() + AbsolutePathBuf::try_from(repo_root.join("first")).unwrap() ); } } diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 93f903d762..179647854c 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,3 +1,4 @@ +mod curated_repo; mod injection; mod manager; mod manifest; @@ -5,6 +6,8 @@ mod marketplace; mod render; mod store; +pub(crate) use curated_repo::curated_plugins_repo_path; +pub(crate) use curated_repo::sync_openai_plugins_repo; pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; pub use manager::ConfiguredMarketplacePluginSummary; @@ -17,8 +20,12 @@ pub use manager::PluginLoadOutcome; pub use manager::PluginsManager; pub use manager::load_plugin_apps; pub(crate) use manager::plugin_namespace_for_skill_path; +pub use manifest::PluginManifestInterfaceSummary; +pub(crate) use manifest::PluginManifestPaths; pub(crate) use manifest::load_plugin_manifest; +pub(crate) use manifest::plugin_manifest_interface; pub(crate) use manifest::plugin_manifest_name; +pub(crate) use manifest::plugin_manifest_paths; pub use marketplace::MarketplaceError; pub use marketplace::MarketplacePluginSourceSummary; pub(crate) use render::render_explicit_plugin_instructions; diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 74ae605156..60da25ecb7 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -61,7 +61,7 @@ impl PluginId { pub struct PluginInstallResult { pub plugin_id: PluginId, pub plugin_version: String, - pub installed_path: PathBuf, + pub installed_path: AbsolutePathBuf, } #[derive(Debug, Clone)] @@ -92,19 +92,25 @@ impl PluginStore { .unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}")) } + pub fn is_installed(&self, plugin_id: &PluginId) -> bool { + self.plugin_root(plugin_id, DEFAULT_PLUGIN_VERSION) + .as_path() + .is_dir() + } + pub fn install( &self, - source_path: PathBuf, + source_path: AbsolutePathBuf, plugin_id: PluginId, ) -> Result { - if !source_path.is_dir() { + if !source_path.as_path().is_dir() { return Err(PluginStoreError::Invalid(format!( "plugin source path is not a directory: {}", source_path.display() ))); } - let plugin_name = plugin_name_for_source(&source_path)?; + let plugin_name = plugin_name_for_source(source_path.as_path())?; if plugin_name != plugin_id.plugin_name { return Err(PluginStoreError::Invalid(format!( "plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`", @@ -112,18 +118,16 @@ impl PluginStore { ))); } let plugin_version = DEFAULT_PLUGIN_VERSION.to_string(); - let installed_path = self - .plugin_root(&plugin_id, &plugin_version) - .into_path_buf(); + let installed_path = self.plugin_root(&plugin_id, &plugin_version); if let Some(parent) = installed_path.parent() { - fs::create_dir_all(parent).map_err(|err| { + fs::create_dir_all(parent.as_path()).map_err(|err| { PluginStoreError::io("failed to create plugin cache directory", err) })?; } - remove_existing_target(&installed_path)?; - copy_dir_recursive(&source_path, &installed_path)?; + remove_existing_target(installed_path.as_path())?; + copy_dir_recursive(source_path.as_path(), installed_path.as_path())?; Ok(PluginInstallResult { plugin_id, @@ -257,7 +261,10 @@ mod tests { let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); let result = PluginStore::new(tmp.path().to_path_buf()) - .install(tmp.path().join("sample-plugin"), plugin_id.clone()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + ) .unwrap(); let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local"); @@ -266,7 +273,7 @@ mod tests { PluginInstallResult { plugin_id, plugin_version: "local".to_string(), - installed_path: installed_path.clone(), + installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(), } ); assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); @@ -280,7 +287,10 @@ mod tests { let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap(); let result = PluginStore::new(tmp.path().to_path_buf()) - .install(tmp.path().join("source-dir"), plugin_id.clone()) + .install( + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), + plugin_id.clone(), + ) .unwrap(); assert_eq!( @@ -288,7 +298,10 @@ mod tests { PluginInstallResult { plugin_id, plugin_version: "local".to_string(), - installed_path: tmp.path().join("plugins/cache/market/manifest-name/local"), + installed_path: AbsolutePathBuf::try_from( + tmp.path().join("plugins/cache/market/manifest-name/local"), + ) + .unwrap(), } ); } @@ -327,7 +340,7 @@ mod tests { let err = PluginStore::new(tmp.path().to_path_buf()) .install( - tmp.path().join("source-dir"), + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(), ) .unwrap_err(); @@ -355,7 +368,7 @@ mod tests { let err = PluginStore::new(tmp.path().to_path_buf()) .install( - tmp.path().join("source-dir"), + AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(), PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(), ) .unwrap_err(); From 8b812849758ea205e31da8979e66bef01d43380e Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Fri, 6 Mar 2026 16:40:41 -0800 Subject: [PATCH 07/13] fix(core): skip exec approval for permissionless skill scripts (#13791) ## Summary - Treat skill scripts with no permission profile, or an explicitly empty one, as permissionless and run them with the turn's existing sandbox instead of forcing an exec approval prompt. - Keep the approval flow unchanged for skills that do declare additional permissions. - Update the skill approval tests to assert that permissionless skill scripts do not prompt on either the initial run or a rerun. ## Why Permissionless skills should inherit the current turn sandbox directly. Prompting for exec approval in that case adds friction without granting any additional capability. --- .../tools/runtimes/shell/unix_escalation.rs | 21 ++++++-- codex-rs/core/tests/suite/skill_approval.rs | 53 ++++++------------- 2 files changed, 33 insertions(+), 41 deletions(-) diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index e4f7c80ab7..28f4a65bea 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -570,9 +570,22 @@ impl EscalationPolicy for CoreShellActionProvider { // In the usual case, the execve wrapper reports the command being // executed in `program`, so a direct skill lookup is sufficient. if let Some(skill) = self.find_skill(program).await { - // For now, we always prompt for scripts that look like they belong - // to skills, which means we ignore exec policy rules for those - // scripts. + // For now, scripts that look like they belong to skills bypass + // general exec policy evaluation. Permissionless skills inherit the + // turn sandbox directly; skills with declared permissions still + // prompt here before applying their permission profile. + let prompt_permissions = skill.permission_profile.clone(); + if prompt_permissions + .as_ref() + .is_none_or(PermissionProfile::is_empty) + { + tracing::debug!( + "Matched {program:?} to permissionless skill {skill:?}, inheriting turn sandbox" + ); + return Ok(EscalationDecision::escalate( + EscalationExecution::TurnDefault, + )); + } tracing::debug!("Matched {program:?} to skill {skill:?}, prompting for approval"); let needs_escalation = true; let decision_source = DecisionSource::SkillScript { @@ -585,7 +598,7 @@ impl EscalationPolicy for CoreShellActionProvider { program, argv, workdir, - skill.permission_profile.clone(), + prompt_permissions, Self::skill_escalation_execution(&skill), decision_source, ) diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d81cbb5c66..ec25d838ad 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -265,8 +265,7 @@ permissions: Ok(()) } -/// Look for `additional_permissions == None`, then verify that both the first -/// run and the cached session-approval rerun stay inside the turn sandbox. +/// Permissionless skills should inherit the turn sandbox without prompting. #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Result<()> { @@ -307,7 +306,7 @@ async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Res ) .await?; - let (script_path_str, command) = skill_script_command(&test, "sandboxed.sh")?; + let (_, command) = skill_script_command(&test, "sandboxed.sh")?; let first_call_id = "zsh-fork-skill-permissions-1"; let first_arguments = shell_command_arguments(&command)?; @@ -327,22 +326,11 @@ async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Res ) .await?; - let maybe_approval = wait_for_exec_approval_request(&test).await; - let approval = match maybe_approval { - Some(approval) => approval, - None => panic!("expected exec approval request before completion"), - }; - assert_eq!(approval.call_id, first_call_id); - assert_eq!(approval.command, vec![script_path_str.clone()]); - assert_eq!(approval.additional_permissions, None); - - test.codex - .submit(Op::ExecApproval { - id: approval.effective_approval_id(), - turn_id: None, - decision: ReviewDecision::ApprovedForSession, - }) - .await?; + let first_approval = wait_for_exec_approval_request(&test).await; + assert!( + first_approval.is_none(), + "expected permissionless skill script to skip exec approval" + ); wait_for_turn_complete(&test).await; @@ -383,7 +371,7 @@ async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Res let cached_approval = wait_for_exec_approval_request(&test).await; assert!( cached_approval.is_none(), - "expected second run to reuse the cached session approval" + "expected permissionless skill rerun to continue skipping exec approval" ); let second_output = second_mocks @@ -406,7 +394,7 @@ async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Res } /// Empty skill permissions should behave like no skill override and inherit the -/// turn sandbox instead of forcing an explicit read-only skill sandbox. +/// turn sandbox without prompting. #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_zsh_fork_skill_with_empty_permissions_inherits_turn_sandbox() -> Result<()> { @@ -447,7 +435,7 @@ async fn shell_zsh_fork_skill_with_empty_permissions_inherits_turn_sandbox() -> ) .await?; - let (script_path_str, command) = skill_script_command(&test, "sandboxed.sh")?; + let (_, command) = skill_script_command(&test, "sandboxed.sh")?; let first_call_id = "zsh-fork-skill-empty-permissions-1"; let first_arguments = shell_command_arguments(&command)?; @@ -467,20 +455,11 @@ async fn shell_zsh_fork_skill_with_empty_permissions_inherits_turn_sandbox() -> ) .await?; - let approval = wait_for_exec_approval_request(&test) - .await - .expect("expected exec approval request before completion"); - assert_eq!(approval.call_id, first_call_id); - assert_eq!(approval.command, vec![script_path_str.clone()]); - assert_eq!(approval.additional_permissions, None); - - test.codex - .submit(Op::ExecApproval { - id: approval.effective_approval_id(), - turn_id: None, - decision: ReviewDecision::ApprovedForSession, - }) - .await?; + let first_approval = wait_for_exec_approval_request(&test).await; + assert!( + first_approval.is_none(), + "expected empty skill permissions to skip exec approval" + ); wait_for_turn_complete(&test).await; @@ -520,7 +499,7 @@ async fn shell_zsh_fork_skill_with_empty_permissions_inherits_turn_sandbox() -> let cached_approval = wait_for_exec_approval_request(&test).await; assert!( cached_approval.is_none(), - "expected second run to reuse the cached session approval" + "expected empty-permissions skill rerun to continue skipping exec approval" ); let second_output = second_mocks From 61098c7f5121824331d81513d51d852eda4b1509 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Fri, 6 Mar 2026 19:50:50 -0500 Subject: [PATCH 08/13] Allow full web search tool config (#13675) Previously, we could only configure whether web search was on/off. This PR enables sending along a web search config, which includes all the stuff responsesapi supports: filters, location, etc. --- .../codex_app_server_protocol.schemas.json | 93 ++++++++- .../codex_app_server_protocol.v2.schemas.json | 93 ++++++++- .../schema/json/v2/ConfigReadResponse.json | 93 ++++++++- .../schema/typescript/WebSearchContextSize.ts | 5 + .../schema/typescript/WebSearchLocation.ts | 5 + .../schema/typescript/WebSearchToolConfig.ts | 7 + .../schema/typescript/index.ts | 3 + .../schema/typescript/v2/ProfileV2.ts | 3 +- .../schema/typescript/v2/ToolsV2.ts | 3 +- .../app-server-protocol/src/protocol/v2.rs | 5 +- .../app-server/tests/suite/v2/config_rpc.rs | 76 +++++++- codex-rs/core/config.schema.json | 58 +++++- codex-rs/core/src/client_common.rs | 77 ++++++++ codex-rs/core/src/codex.rs | 3 + codex-rs/core/src/config/config_tests.rs | 32 +--- codex-rs/core/src/config/managed_features.rs | 15 -- codex-rs/core/src/config/mod.rs | 34 +++- codex-rs/core/src/config/profile.rs | 3 +- codex-rs/core/src/features.rs | 12 +- codex-rs/core/src/features/legacy.rs | 7 - codex-rs/core/src/tools/spec.rs | 74 ++++++++ codex-rs/core/tests/suite/web_search.rs | 60 ++++++ codex-rs/protocol/src/config_types.rs | 178 ++++++++++++++++++ 23 files changed, 860 insertions(+), 79 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/WebSearchContextSize.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/WebSearchLocation.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/WebSearchToolConfig.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 6ab65596aa..296f92a979 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 @@ -12108,6 +12108,16 @@ } ] }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ToolsV2" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -15848,9 +15858,13 @@ ] }, "web_search": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/WebSearchToolConfig" + }, + { + "type": "null" + } ] } }, @@ -16449,6 +16463,44 @@ } ] }, + "WebSearchContextSize": { + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchLocation": { + "additionalProperties": false, + "properties": { + "city": { + "type": [ + "string", + "null" + ] + }, + "country": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ] + }, + "timezone": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "WebSearchMode": { "enum": [ "disabled", @@ -16457,6 +16509,41 @@ ], "type": "string" }, + "WebSearchToolConfig": { + "additionalProperties": false, + "properties": { + "allowed_domains": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "context_size": { + "anyOf": [ + { + "$ref": "#/definitions/v2/WebSearchContextSize" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/definitions/v2/WebSearchLocation" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "WindowsSandboxSetupCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 2e0eef061f..fb731fa356 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 @@ -8765,6 +8765,16 @@ } ] }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsV2" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -13880,9 +13890,13 @@ ] }, "web_search": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/WebSearchToolConfig" + }, + { + "type": "null" + } ] } }, @@ -14705,6 +14719,44 @@ } ] }, + "WebSearchContextSize": { + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchLocation": { + "additionalProperties": false, + "properties": { + "city": { + "type": [ + "string", + "null" + ] + }, + "country": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ] + }, + "timezone": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "WebSearchMode": { "enum": [ "disabled", @@ -14713,6 +14765,41 @@ ], "type": "string" }, + "WebSearchToolConfig": { + "additionalProperties": false, + "properties": { + "allowed_domains": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "context_size": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchContextSize" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchLocation" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, "WindowsSandboxSetupCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index dd0a86fe91..90828da0ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -628,6 +628,16 @@ } ] }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsV2" + }, + { + "type": "null" + } + ] + }, "web_search": { "anyOf": [ { @@ -721,9 +731,13 @@ ] }, "web_search": { - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/WebSearchToolConfig" + }, + { + "type": "null" + } ] } }, @@ -738,6 +752,44 @@ ], "type": "string" }, + "WebSearchContextSize": { + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchLocation": { + "additionalProperties": false, + "properties": { + "city": { + "type": [ + "string", + "null" + ] + }, + "country": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ] + }, + "timezone": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "WebSearchMode": { "enum": [ "disabled", @@ -745,6 +797,41 @@ "live" ], "type": "string" + }, + "WebSearchToolConfig": { + "additionalProperties": false, + "properties": { + "allowed_domains": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "context_size": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchContextSize" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchLocation" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchContextSize.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchContextSize.ts new file mode 100644 index 0000000000..d6feedde84 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchContextSize.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 WebSearchContextSize = "low" | "medium" | "high"; diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchLocation.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchLocation.ts new file mode 100644 index 0000000000..12319983d7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchLocation.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 WebSearchLocation = { country: string | null, region: string | null, city: string | null, timezone: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/WebSearchToolConfig.ts b/codex-rs/app-server-protocol/schema/typescript/WebSearchToolConfig.ts new file mode 100644 index 0000000000..c14067cef4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/WebSearchToolConfig.ts @@ -0,0 +1,7 @@ +// 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 { WebSearchContextSize } from "./WebSearchContextSize"; +import type { WebSearchLocation } from "./WebSearchLocation"; + +export type WebSearchToolConfig = { context_size: WebSearchContextSize | null, allowed_domains: Array | null, location: WebSearchLocation | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 67b98c3946..af0db3367e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -211,7 +211,10 @@ export type { ViewImageToolCallEvent } from "./ViewImageToolCallEvent"; export type { WarningEvent } from "./WarningEvent"; export type { WebSearchAction } from "./WebSearchAction"; export type { WebSearchBeginEvent } from "./WebSearchBeginEvent"; +export type { WebSearchContextSize } from "./WebSearchContextSize"; export type { WebSearchEndEvent } from "./WebSearchEndEvent"; export type { WebSearchItem } from "./WebSearchItem"; +export type { WebSearchLocation } from "./WebSearchLocation"; export type { WebSearchMode } from "./WebSearchMode"; +export type { WebSearchToolConfig } from "./WebSearchToolConfig"; export * as v2 from "./v2"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index 81d20993cb..f2c72b3ae6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -8,5 +8,6 @@ import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; +import type { ToolsV2 } from "./ToolsV2"; -export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +export type ProfileV2 = { model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null, } & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts index 0b1bee5146..784991f017 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WebSearchToolConfig } from "../WebSearchToolConfig"; -export type ToolsV2 = { web_search: boolean | null, view_image: boolean | null, }; +export type ToolsV2 = { web_search: WebSearchToolConfig | null, view_image: boolean | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 99d55489bd..5063f82182 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -22,6 +22,7 @@ use codex_protocol::config_types::SandboxMode as CoreSandboxMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::mcp::Resource as McpResource; @@ -375,8 +376,7 @@ pub struct SandboxWorkspaceWrite { #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] pub struct ToolsV2 { - #[serde(alias = "web_search_request")] - pub web_search: Option, + pub web_search: Option, pub view_image: Option, } @@ -401,6 +401,7 @@ pub struct ProfileV2 { pub model_reasoning_summary: Option, pub model_verbosity: Option, pub web_search: Option, + pub tools: Option, pub chatgpt_base_url: Option, #[serde(default, flatten)] pub additional: HashMap, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index cd74710876..1b73589a6b 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -23,6 +23,9 @@ use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::WebSearchContextSize; +use codex_protocol::config_types::WebSearchLocation; +use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -93,8 +96,11 @@ async fn config_read_includes_tools() -> Result<()> { r#" model = "gpt-user" +[tools.web_search] +context_size = "low" +allowed_domains = ["example.com"] + [tools] -web_search = true view_image = false "#, )?; @@ -125,12 +131,28 @@ view_image = false assert_eq!( tools, ToolsV2 { - web_search: Some(true), + web_search: Some(WebSearchToolConfig { + context_size: Some(WebSearchContextSize::Low), + allowed_domains: Some(vec!["example.com".to_string()]), + location: None, + }), view_image: Some(false), } ); assert_eq!( - origins.get("tools.web_search").expect("origin").name, + origins + .get("tools.web_search.context_size") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins + .get("tools.web_search.allowed_domains.0") + .expect("origin") + .name, ConfigLayerSource::User { file: user_file.clone(), } @@ -148,6 +170,54 @@ view_image = false Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_nested_web_search_tool_config() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +web_search = "live" + +[tools.web_search] +context_size = "high" +allowed_domains = ["example.com"] +location = { country = "US", city = "New York", timezone = "America/New_York" } +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + assert_eq!( + config.tools.expect("tools present").web_search, + Some(WebSearchToolConfig { + context_size: Some(WebSearchContextSize::High), + allowed_domains: Some(vec!["example.com".to_string()]), + location: Some(WebSearchLocation { + country: Some("US".to_string()), + region: None, + city: Some("New York".to_string()), + timezone: Some("America/New_York".to_string()), + }), + }), + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_apps() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2edde6eb98..c9d65cc36d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -530,10 +530,10 @@ "service_tier": { "$ref": "#/definitions/ServiceTier" }, - "tools_view_image": { - "type": "boolean" + "tools": { + "$ref": "#/definitions/ToolsToml" }, - "tools_web_search": { + "tools_view_image": { "type": "boolean" }, "web_search": { @@ -1464,8 +1464,12 @@ "type": "boolean" }, "web_search": { - "default": null, - "type": "boolean" + "allOf": [ + { + "$ref": "#/definitions/WebSearchToolConfig" + } + ], + "default": null } }, "type": "object" @@ -1573,6 +1577,32 @@ ], "type": "string" }, + "WebSearchContextSize": { + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchLocation": { + "additionalProperties": false, + "properties": { + "city": { + "type": "string" + }, + "country": { + "type": "string" + }, + "region": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "type": "object" + }, "WebSearchMode": { "enum": [ "disabled", @@ -1581,6 +1611,24 @@ ], "type": "string" }, + "WebSearchToolConfig": { + "additionalProperties": false, + "properties": { + "allowed_domains": { + "items": { + "type": "string" + }, + "type": "array" + }, + "context_size": { + "$ref": "#/definitions/WebSearchContextSize" + }, + "location": { + "$ref": "#/definitions/WebSearchLocation" + } + }, + "type": "object" + }, "WindowsSandboxModeToml": { "enum": [ "elevated", diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index b2bd4d0b3a..1fb3b7b47c 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -154,8 +154,13 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { pub(crate) mod tools { use crate::tools::spec::JsonSchema; + use codex_protocol::config_types::WebSearchContextSize; + use codex_protocol::config_types::WebSearchFilters; + use codex_protocol::config_types::WebSearchUserLocation; + use codex_protocol::config_types::WebSearchUserLocationType; use serde::Deserialize; use serde::Serialize; + use serde::Serializer; /// When serialized as JSON, this produces a valid "Tool" in the OpenAI /// Responses API. @@ -176,6 +181,18 @@ pub(crate) mod tools { WebSearch { #[serde(skip_serializing_if = "Option::is_none")] external_web_access: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_web_search_filters" + )] + filters: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_web_search_user_location" + )] + user_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + search_context_size: Option, #[serde(skip_serializing_if = "Option::is_none")] search_content_types: Option>, }, @@ -195,6 +212,66 @@ pub(crate) mod tools { } } + fn serialize_web_search_filters( + filters: &Option, + serializer: S, + ) -> Result + where + S: Serializer, + { + match filters { + Some(filters) => { + #[derive(Serialize)] + struct SerializableWebSearchFilters<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + allowed_domains: Option<&'a Vec>, + } + + SerializableWebSearchFilters { + allowed_domains: filters.allowed_domains.as_ref(), + } + .serialize(serializer) + } + None => serializer.serialize_none(), + } + } + + fn serialize_web_search_user_location( + user_location: &Option, + serializer: S, + ) -> Result + where + S: Serializer, + { + match user_location { + Some(user_location) => { + #[derive(Serialize)] + struct SerializableWebSearchUserLocation<'a> { + #[serde(rename = "type")] + r#type: WebSearchUserLocationType, + #[serde(skip_serializing_if = "Option::is_none")] + country: Option<&'a String>, + #[serde(skip_serializing_if = "Option::is_none")] + region: Option<&'a String>, + #[serde(skip_serializing_if = "Option::is_none")] + city: Option<&'a String>, + #[serde(skip_serializing_if = "Option::is_none")] + timezone: Option<&'a String>, + } + + SerializableWebSearchUserLocation { + r#type: user_location.r#type, + country: user_location.country.as_ref(), + region: user_location.region.as_ref(), + city: user_location.city.as_ref(), + timezone: user_location.timezone.as_ref(), + } + .serialize(serializer) + } + None => serializer.serialize_none(), + } + } + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct FreeformTool { pub(crate) name: String, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 519b28217d..4292552a31 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -744,6 +744,7 @@ impl TurnContext { web_search_mode: self.tools_config.web_search_mode, session_source: self.session_source.clone(), }) + .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); @@ -1119,6 +1120,7 @@ impl Session { web_search_mode: Some(per_turn_config.web_search_mode.value()), session_source: session_source.clone(), }) + .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) .with_agent_roles(per_turn_config.agent_roles.clone()); @@ -4911,6 +4913,7 @@ async fn spawn_review_thread( web_search_mode: Some(review_web_search_mode), session_source: parent_turn_context.session_source.clone(), }) + .with_web_search_config(None) .with_allow_login_shell(config.permissions.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 87f345b22f..ebf4862bcc 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1250,34 +1250,6 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu Ok(()) } -#[test] -fn profile_legacy_toggles_override_base() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let mut profiles = HashMap::new(); - profiles.insert( - "work".to_string(), - ConfigProfile { - tools_web_search: Some(false), - ..Default::default() - }, - ); - let cfg = ConfigToml { - profiles, - profile: Some("work".to_string()), - ..Default::default() - }; - - let config = Config::load_from_base_config_with_overrides( - cfg, - ConfigOverrides::default(), - codex_home.path().to_path_buf(), - )?; - - assert!(!config.features.enabled(Feature::WebSearchRequest)); - - Ok(()) -} - #[tokio::test] async fn project_profile_overrides_user_profile() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3100,6 +3072,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { forced_login_method: None, include_apply_patch_tool: false, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, ghost_snapshot: GhostSnapshotConfig::default(), @@ -3234,6 +3207,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { forced_login_method: None, include_apply_patch_tool: false, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, ghost_snapshot: GhostSnapshotConfig::default(), @@ -3366,6 +3340,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { forced_login_method: None, include_apply_patch_tool: false, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, ghost_snapshot: GhostSnapshotConfig::default(), @@ -3484,6 +3459,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { forced_login_method: None, include_apply_patch_tool: false, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, ghost_snapshot: GhostSnapshotConfig::default(), diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 13510b0d85..4e45dedf91 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -218,14 +218,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature enabled, )); } - if let Some(enabled) = cfg.tools.as_ref().and_then(|tools| tools.web_search) { - explicit_settings.push(( - "tools.web_search".to_string(), - Feature::WebSearchRequest, - enabled, - )); - } - for (profile_name, profile) in &cfg.profiles { if let Some(features) = profile.features.as_ref() { for (key, enabled) in &features.entries { @@ -259,13 +251,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature enabled, )); } - if let Some(enabled) = profile.tools_web_search { - explicit_settings.push(( - format!("profiles.{profile_name}.tools_web_search"), - Feature::WebSearchRequest, - enabled, - )); - } } explicit_settings diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3a250c09e5..9f636d2684 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -68,7 +68,9 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::openai_models::ModelsResponse; @@ -482,6 +484,9 @@ pub struct Config { /// Explicit or feature-derived web search mode. pub web_search_mode: Constrained, + /// Additional parameters for the web search tool when it is enabled. + pub web_search_config: Option, + /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -1379,8 +1384,8 @@ pub struct RealtimeAudioToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ToolsToml { - #[serde(default, alias = "web_search_request")] - pub web_search: Option, + #[serde(default)] + pub web_search: Option, /// Enable the `view_image` tool that lets the agent attach local images. #[serde(default)] @@ -1442,7 +1447,7 @@ pub struct AgentRoleToml { impl From for Tools { fn from(tools_toml: ToolsToml) -> Self { Self { - web_search: tools_toml.web_search, + web_search: tools_toml.web_search.is_some().then_some(true), view_image: tools_toml.view_image, } } @@ -1734,6 +1739,27 @@ fn resolve_web_search_mode( None } +fn resolve_web_search_config( + config_toml: &ConfigToml, + config_profile: &ConfigProfile, +) -> Option { + let base = config_toml + .tools + .as_ref() + .and_then(|tools| tools.web_search.as_ref()); + let profile = config_profile + .tools + .as_ref() + .and_then(|tools| tools.web_search.as_ref()); + + match (base, profile) { + (None, None) => None, + (Some(base), None) => Some(base.clone().into()), + (None, Some(profile)) => Some(profile.clone().into()), + (Some(base), Some(profile)) => Some(base.merge(profile).into()), + } +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, sandbox_policy: &SandboxPolicy, @@ -2013,6 +2039,7 @@ impl Config { } let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features) .unwrap_or(WebSearchMode::Cached); + let web_search_config = resolve_web_search_config(&cfg, &config_profile); let mut model_providers = built_in_model_providers(); // Merge user-defined providers into the built-in list. @@ -2418,6 +2445,7 @@ impl Config { forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, web_search_mode: constrained_web_search_mode.value, + web_search_config, use_experimental_unified_exec_tool, background_terminal_max_timeout, ghost_snapshot, diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 6d4cd23090..ce454ff0a8 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -3,6 +3,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use crate::config::ToolsToml; use crate::config::types::Personality; use crate::config::types::WindowsToml; use crate::protocol::AskForApproval; @@ -47,8 +48,8 @@ pub struct ConfigProfile { pub include_apply_patch_tool: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, - pub tools_web_search: Option, pub tools_view_image: Option, + pub tools: Option, pub web_search: Option, pub analytics: Option, #[serde(default)] diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 43c0b06b64..87f37f43d5 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -213,10 +213,17 @@ impl FeatureOverrides { fn apply(self, features: &mut Features) { LegacyFeatureToggles { include_apply_patch_tool: self.include_apply_patch_tool, - tools_web_search: self.web_search_request, ..Default::default() } .apply(features); + if let Some(enabled) = self.web_search_request { + if enabled { + features.enable(Feature::WebSearchRequest); + } else { + features.disable(Feature::WebSearchRequest); + } + features.record_legacy_usage("web_search_request", Feature::WebSearchRequest); + } } } @@ -342,7 +349,6 @@ impl Features { let base_legacy = LegacyFeatureToggles { experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, - tools_web_search: cfg.tools.as_ref().and_then(|t| t.web_search), ..Default::default() }; base_legacy.apply(&mut features); @@ -357,7 +363,6 @@ impl Features { .experimental_use_freeform_apply_patch, experimental_use_unified_exec_tool: config_profile.experimental_use_unified_exec_tool, - tools_web_search: config_profile.tools_web_search, }; profile_legacy.apply(&mut features); if let Some(profile_features) = config_profile.features.as_ref() { @@ -388,7 +393,6 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option Feature::WebSearchRequest | Feature::WebSearchCached => { let label = match alias { "web_search" => "[features].web_search", - "tools.web_search" => "[tools].web_search", "features.web_search_request" | "web_search_request" => { "[features].web_search_request" } diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 45b3dfd5dc..b7aa30482a 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -62,7 +62,6 @@ pub struct LegacyFeatureToggles { pub include_apply_patch_tool: Option, pub experimental_use_freeform_apply_patch: Option, pub experimental_use_unified_exec_tool: Option, - pub tools_web_search: Option, } impl LegacyFeatureToggles { @@ -85,12 +84,6 @@ impl LegacyFeatureToggles { self.experimental_use_unified_exec_tool, "experimental_use_unified_exec_tool", ); - set_if_some( - features, - Feature::WebSearchRequest, - self.tools_web_search, - "tools.web_search", - ); } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index f0c952e078..9c6cb95a64 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -18,6 +18,7 @@ use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; +use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::VIEW_IMAGE_TOOL_NAME; @@ -58,6 +59,7 @@ pub(crate) struct ToolsConfig { pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, + pub web_search_config: Option, pub web_search_tool_type: WebSearchToolType, pub image_gen_tool: bool, pub agent_roles: BTreeMap, @@ -158,6 +160,7 @@ impl ToolsConfig { allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, + web_search_config: None, web_search_tool_type: model_info.web_search_tool_type, image_gen_tool: include_image_gen_tool, agent_roles: BTreeMap::new(), @@ -184,6 +187,11 @@ impl ToolsConfig { self.allow_login_shell = allow_login_shell; self } + + pub fn with_web_search_config(mut self, web_search_config: Option) -> Self { + self.web_search_config = web_search_config; + self + } } fn supports_image_generation(model_info: &ModelInfo) -> bool { @@ -1979,6 +1987,18 @@ pub(crate) fn build_specs( builder.push_spec(ToolSpec::WebSearch { external_web_access: Some(external_web_access), + filters: config + .web_search_config + .as_ref() + .and_then(|cfg| cfg.filters.clone()), + user_location: config + .web_search_config + .as_ref() + .and_then(|cfg| cfg.user_location.clone()), + search_context_size: config + .web_search_config + .as_ref() + .and_then(|cfg| cfg.search_context_size), search_content_types, }); } @@ -2266,6 +2286,9 @@ mod tests { create_apply_patch_freeform_tool(), ToolSpec::WebSearch { external_web_access: Some(true), + filters: None, + user_location: None, + search_context_size: None, search_content_types: None, }, create_view_image_tool(), @@ -2592,6 +2615,9 @@ mod tests { tool.spec, ToolSpec::WebSearch { external_web_access: Some(false), + filters: None, + user_location: None, + search_context_size: None, search_content_types: None, } ); @@ -2617,6 +2643,51 @@ mod tests { tool.spec, ToolSpec::WebSearch { external_web_access: Some(true), + filters: None, + user_location: None, + search_context_size: None, + search_content_types: None, + } + ); + } + + #[test] + fn web_search_config_is_forwarded_to_tool_spec() { + let config = test_config(); + let model_info = + ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let features = Features::with_defaults(); + let web_search_config = WebSearchConfig { + filters: Some(codex_protocol::config_types::WebSearchFilters { + allowed_domains: Some(vec!["example.com".to_string()]), + }), + user_location: Some(codex_protocol::config_types::WebSearchUserLocation { + r#type: codex_protocol::config_types::WebSearchUserLocationType::Approximate, + country: Some("US".to_string()), + region: Some("California".to_string()), + city: Some("San Francisco".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + }), + search_context_size: Some(codex_protocol::config_types::WebSearchContextSize::High), + }; + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + }) + .with_web_search_config(Some(web_search_config.clone())); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let tool = find_tool(&tools, "web_search"); + assert_eq!( + tool.spec, + ToolSpec::WebSearch { + external_web_access: Some(true), + filters: web_search_config.filters, + user_location: web_search_config.user_location, + search_context_size: web_search_config.search_context_size, search_content_types: None, } ); @@ -2643,6 +2714,9 @@ mod tests { tool.spec, ToolSpec::WebSearch { external_web_access: Some(true), + filters: None, + user_location: None, + search_context_size: None, search_content_types: Some( WEB_SEARCH_CONTENT_TYPES .into_iter() diff --git a/codex-rs/core/tests/suite/web_search.rs b/codex-rs/core/tests/suite/web_search.rs index 6df0338e92..c90ca91235 100644 --- a/codex-rs/core/tests/suite/web_search.rs +++ b/codex-rs/core/tests/suite/web_search.rs @@ -9,6 +9,8 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; +use std::sync::Arc; #[allow(clippy::expect_used)] fn find_web_search_tool(body: &Value) -> &Value { @@ -223,3 +225,61 @@ async fn web_search_mode_updates_between_turns_with_sandbox_policy() { "danger-full-access policy should default web_search to live" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn web_search_tool_config_from_config_toml_is_forwarded_to_request() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let sse = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]); + let resp_mock = responses::mount_sse_once(&server, sse).await; + + let home = Arc::new(tempfile::TempDir::new().expect("create codex home")); + std::fs::write( + home.path().join("config.toml"), + r#"web_search = "live" + +[tools.web_search] +context_size = "high" +allowed_domains = ["example.com"] +location = { country = "US", city = "New York", timezone = "America/New_York" } +"#, + ) + .expect("write config.toml"); + + let mut builder = test_codex().with_model("gpt-5-codex").with_home(home); + let test = builder + .build(&server) + .await + .expect("create test Codex conversation"); + + test.submit_turn_with_policy( + "hello configured web search", + SandboxPolicy::DangerFullAccess, + ) + .await + .expect("submit turn"); + + let body = resp_mock.single_request().body_json(); + let tool = find_web_search_tool(&body); + assert_eq!( + tool, + &json!({ + "type": "web_search", + "external_web_access": true, + "search_context_size": "high", + "filters": { + "allowed_domains": ["example.com"], + }, + "user_location": { + "type": "approximate", + "country": "US", + "city": "New York", + "timezone": "America/New_York", + }, + }) + ); +} diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index b467d34857..4261bb8d1d 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -113,6 +113,122 @@ pub enum WebSearchMode { Live, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum WebSearchContextSize { + Low, + Medium, + High, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[schemars(deny_unknown_fields)] +pub struct WebSearchLocation { + pub country: Option, + pub region: Option, + pub city: Option, + pub timezone: Option, +} + +impl WebSearchLocation { + pub fn merge(&self, other: &Self) -> Self { + Self { + country: other.country.clone().or_else(|| self.country.clone()), + region: other.region.clone().or_else(|| self.region.clone()), + city: other.city.clone().or_else(|| self.city.clone()), + timezone: other.timezone.clone().or_else(|| self.timezone.clone()), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[schemars(deny_unknown_fields)] +pub struct WebSearchToolConfig { + pub context_size: Option, + pub allowed_domains: Option>, + pub location: Option, +} + +impl WebSearchToolConfig { + pub fn merge(&self, other: &Self) -> Self { + Self { + context_size: other.context_size.or(self.context_size), + allowed_domains: other + .allowed_domains + .clone() + .or_else(|| self.allowed_domains.clone()), + location: match (&self.location, &other.location) { + (Some(location), Some(other_location)) => Some(location.merge(other_location)), + (Some(location), None) => Some(location.clone()), + (None, Some(other_location)) => Some(other_location.clone()), + (None, None) => None, + }, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[schemars(deny_unknown_fields)] +pub struct WebSearchFilters { + pub allowed_domains: Option>, +} + +#[derive( + Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Display, JsonSchema, TS, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum WebSearchUserLocationType { + #[default] + Approximate, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[schemars(deny_unknown_fields)] +pub struct WebSearchUserLocation { + #[serde(default)] + pub r#type: WebSearchUserLocationType, + pub country: Option, + pub region: Option, + pub city: Option, + pub timezone: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[schemars(deny_unknown_fields)] +pub struct WebSearchConfig { + pub filters: Option, + pub user_location: Option, + pub search_context_size: Option, +} + +impl From for WebSearchUserLocation { + fn from(location: WebSearchLocation) -> Self { + Self { + r#type: WebSearchUserLocationType::Approximate, + country: location.country, + region: location.region, + city: location.city, + timezone: location.timezone, + } + } +} + +impl From for WebSearchConfig { + fn from(config: WebSearchToolConfig) -> Self { + Self { + filters: config + .allowed_domains + .map(|allowed_domains| WebSearchFilters { + allowed_domains: Some(allowed_domains), + }), + user_location: config.location.map(Into::into), + search_context_size: config.context_size, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] @@ -366,4 +482,66 @@ mod tests { assert!(!ModeKind::PairProgramming.is_tui_visible()); assert!(!ModeKind::Execute.is_tui_visible()); } + + #[test] + fn web_search_location_merge_prefers_overlay_values() { + let base = WebSearchLocation { + country: Some("US".to_string()), + region: Some("CA".to_string()), + city: None, + timezone: Some("America/Los_Angeles".to_string()), + }; + let overlay = WebSearchLocation { + country: None, + region: Some("WA".to_string()), + city: Some("Seattle".to_string()), + timezone: None, + }; + + let expected = WebSearchLocation { + country: Some("US".to_string()), + region: Some("WA".to_string()), + city: Some("Seattle".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + }; + + assert_eq!(expected, base.merge(&overlay)); + } + + #[test] + fn web_search_tool_config_merge_prefers_overlay_values() { + let base = WebSearchToolConfig { + context_size: Some(WebSearchContextSize::Low), + allowed_domains: Some(vec!["openai.com".to_string()]), + location: Some(WebSearchLocation { + country: Some("US".to_string()), + region: Some("CA".to_string()), + city: None, + timezone: Some("America/Los_Angeles".to_string()), + }), + }; + let overlay = WebSearchToolConfig { + context_size: Some(WebSearchContextSize::High), + allowed_domains: None, + location: Some(WebSearchLocation { + country: None, + region: Some("WA".to_string()), + city: Some("Seattle".to_string()), + timezone: None, + }), + }; + + let expected = WebSearchToolConfig { + context_size: Some(WebSearchContextSize::High), + allowed_domains: Some(vec!["openai.com".to_string()]), + location: Some(WebSearchLocation { + country: Some("US".to_string()), + region: Some("WA".to_string()), + city: Some("Seattle".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + }), + }; + + assert_eq!(expected, base.merge(&overlay)); + } } From e9bd8b20a11ada9f674a705ebfbac1fb1aacb11e Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Fri, 6 Mar 2026 17:30:17 -0800 Subject: [PATCH 09/13] app-server: Add streaming and tty/pty capabilities to `command/exec` (#13640) * Add an ability to stream stdin, stdout, and stderr * Streaming of stdout and stderr has a configurable cap for total amount of transmitted bytes (with an ability to disable it) * Add support for overriding environment variables * Add an ability to terminate running applications (using `command/exec/terminate`) * Add TTY/PTY support, with an ability to resize the terminal (using `command/exec/resize`) --- codex-rs/Cargo.lock | 1 + .../schema/json/ClientRequest.json | 225 +++- .../schema/json/ServerNotification.json | 72 ++ .../codex_app_server_protocol.schemas.json | 327 +++++- .../codex_app_server_protocol.v2.schemas.json | 327 +++++- .../CommandExecOutputDeltaNotification.json | 55 + .../schema/json/v2/CommandExecParams.json | 89 +- .../json/v2/CommandExecResizeParams.json | 48 + .../json/v2/CommandExecResizeResponse.json | 6 + .../schema/json/v2/CommandExecResponse.json | 4 + .../json/v2/CommandExecTerminateParams.json | 15 + .../json/v2/CommandExecTerminateResponse.json | 6 + .../json/v2/CommandExecWriteParams.json | 26 + .../json/v2/CommandExecWriteResponse.json | 6 + .../schema/typescript/ClientRequest.ts | 5 +- .../schema/typescript/ServerNotification.ts | 3 +- .../v2/CommandExecOutputDeltaNotification.ts | 30 + .../typescript/v2/CommandExecOutputStream.ts | 8 + .../schema/typescript/v2/CommandExecParams.ts | 93 +- .../typescript/v2/CommandExecResizeParams.ts | 18 + .../v2/CommandExecResizeResponse.ts | 8 + .../typescript/v2/CommandExecResponse.ts | 21 +- .../typescript/v2/CommandExecTerminalSize.ts | 16 + .../v2/CommandExecTerminateParams.ts | 13 + .../v2/CommandExecTerminateResponse.ts | 8 + .../typescript/v2/CommandExecWriteParams.ts | 22 + .../typescript/v2/CommandExecWriteResponse.ts | 8 + .../schema/typescript/v2/index.ts | 9 + .../src/protocol/common.rs | 19 +- .../src/protocol/mappers.rs | 10 +- .../app-server-protocol/src/protocol/v2.rs | 469 ++++++++ codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/Cargo.toml | 3 +- codex-rs/app-server/README.md | 82 +- .../app-server/src/codex_message_processor.rs | 245 +++- codex-rs/app-server/src/command_exec.rs | 1004 +++++++++++++++++ codex-rs/app-server/src/lib.rs | 1 + .../app-server/tests/common/mcp_process.rs | 40 + .../app-server/tests/suite/v2/command_exec.rs | 880 +++++++++++++++ .../suite/v2/connection_handling_websocket.rs | 2 +- codex-rs/app-server/tests/suite/v2/mod.rs | 2 + codex-rs/core/src/exec.rs | 47 +- codex-rs/core/src/lib.rs | 1 + 43 files changed, 4205 insertions(+), 70 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts create mode 100644 codex-rs/app-server/src/command_exec.rs create mode 100644 codex-rs/app-server/tests/suite/v2/command_exec.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 48731eac42..487f3e8995 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1436,6 +1436,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-json-to-toml", + "codex-utils-pty", "core_test_support", "futures", "owo-colors", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 02e428f76b..cbcf8b886f 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -148,14 +148,54 @@ "type": "object" }, "CommandExecParams": { + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", + "type": [ + "string", + "null" + ] + }, + "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + "type": "boolean" + }, + "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -169,14 +209,39 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." + }, + "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + "type": "boolean" + }, + "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", + "type": "boolean" } }, "required": [ @@ -184,6 +249,87 @@ ], "type": "object" }, + "CommandExecResizeParams": { + "description": "Resize a running PTY-backed `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "size": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." + } + }, + "required": [ + "processId", + "size" + ], + "type": "object" + }, + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "CommandExecTerminateParams": { + "description": "Terminate a running `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "type": "object" + }, + "CommandExecWriteParams": { + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", + "properties": { + "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "type": "object" + }, "ConfigBatchWriteParams": { "properties": { "edits": { @@ -3775,7 +3921,7 @@ "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", + "description": "Execute a standalone command (argv vector) under the server's sandbox.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -3799,6 +3945,81 @@ "title": "Command/execRequest", "type": "object" }, + { + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/write" + ], + "title": "Command/exec/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/writeRequest", + "type": "object" + }, + { + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/terminate" + ], + "title": "Command/exec/terminateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecTerminateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/terminateRequest", + "type": "object" + }, + { + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/resize" + ], + "title": "Command/exec/resizeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecResizeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/resizeRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 79f191ee66..fcb205db04 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -670,6 +670,57 @@ } ] }, + "CommandExecOutputDeltaNotification": { + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", + "properties": { + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "type": "object" + }, + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, "CommandExecutionOutputDeltaNotification": { "properties": { "delta": { @@ -3468,6 +3519,27 @@ "title": "Item/plan/deltaNotification", "type": "object" }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", + "properties": { + "method": { + "enum": [ + "command/exec/outputDelta" + ], + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, { "properties": { "method": { 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 296f92a979..3cc179456c 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 @@ -1218,7 +1218,7 @@ "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", + "description": "Execute a standalone command (argv vector) under the server's sandbox.", "properties": { "id": { "$ref": "#/definitions/v2/RequestId" @@ -1242,6 +1242,81 @@ "title": "Command/execRequest", "type": "object" }, + { + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/write" + ], + "title": "Command/exec/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/writeRequest", + "type": "object" + }, + { + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/terminate" + ], + "title": "Command/exec/terminateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecTerminateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/terminateRequest", + "type": "object" + }, + { + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "command/exec/resize" + ], + "title": "Command/exec/resizeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecResizeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/resizeRequest", + "type": "object" + }, { "properties": { "id": { @@ -7081,6 +7156,27 @@ "title": "Item/plan/deltaNotification", "type": "object" }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", + "properties": { + "method": { + "enum": [ + "command/exec/outputDelta" + ], + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/CommandExecOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, { "properties": { "method": { @@ -9319,16 +9415,109 @@ } ] }, + "CommandExecOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", + "properties": { + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/v2/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" + }, + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, "CommandExecParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", + "type": [ + "string", + "null" + ] + }, + "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + "type": "boolean" + }, + "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -9342,14 +9531,39 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/v2/CommandExecTerminalSize" + }, + { + "type": "null" + } + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." + }, + "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + "type": "boolean" + }, + "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", + "type": "boolean" } }, "required": [ @@ -9358,17 +9572,51 @@ "title": "CommandExecParams", "type": "object" }, + "CommandExecResizeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Resize a running PTY-backed `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "size": { + "allOf": [ + { + "$ref": "#/definitions/v2/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." + } + }, + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" + }, + "CommandExecResizeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", + "title": "CommandExecResizeResponse", + "type": "object" + }, "CommandExecResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", "properties": { "exitCode": { + "description": "Process exit code.", "format": "int32", "type": "integer" }, "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", "type": "string" }, "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", "type": "string" } }, @@ -9380,6 +9628,81 @@ "title": "CommandExecResponse", "type": "object" }, + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "CommandExecTerminateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" + }, + "CommandExecTerminateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", + "title": "CommandExecTerminateResponse", + "type": "object" + }, + "CommandExecWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", + "properties": { + "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" + }, + "CommandExecWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", + "title": "CommandExecWriteResponse", + "type": "object" + }, "CommandExecutionOutputDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 fb731fa356..76f1d19d93 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 @@ -1741,7 +1741,7 @@ "type": "object" }, { - "description": "Execute a command (argv vector) under the server's sandbox.", + "description": "Execute a standalone command (argv vector) under the server's sandbox.", "properties": { "id": { "$ref": "#/definitions/RequestId" @@ -1765,6 +1765,81 @@ "title": "Command/execRequest", "type": "object" }, + { + "description": "Write stdin bytes to a running `command/exec` session or close stdin.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/write" + ], + "title": "Command/exec/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/writeRequest", + "type": "object" + }, + { + "description": "Terminate a running `command/exec` session by client-supplied `processId`.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/terminate" + ], + "title": "Command/exec/terminateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecTerminateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/terminateRequest", + "type": "object" + }, + { + "description": "Resize a running PTY-backed `command/exec` session by client-supplied `processId`.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec/resize" + ], + "title": "Command/exec/resizeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecResizeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/exec/resizeRequest", + "type": "object" + }, { "properties": { "id": { @@ -2359,16 +2434,109 @@ } ] }, + "CommandExecOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", + "properties": { + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" + }, + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, "CommandExecParams": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", + "type": [ + "string", + "null" + ] + }, + "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + "type": "boolean" + }, + "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -2382,14 +2550,39 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." + }, + "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + "type": "boolean" + }, + "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", + "type": "boolean" } }, "required": [ @@ -2398,17 +2591,51 @@ "title": "CommandExecParams", "type": "object" }, + "CommandExecResizeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Resize a running PTY-backed `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "size": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." + } + }, + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" + }, + "CommandExecResizeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", + "title": "CommandExecResizeResponse", + "type": "object" + }, "CommandExecResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", "properties": { "exitCode": { + "description": "Process exit code.", "format": "int32", "type": "integer" }, "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", "type": "string" }, "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", "type": "string" } }, @@ -2420,6 +2647,81 @@ "title": "CommandExecResponse", "type": "object" }, + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, + "CommandExecTerminateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" + }, + "CommandExecTerminateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", + "title": "CommandExecTerminateResponse", + "type": "object" + }, + "CommandExecWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", + "properties": { + "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" + }, + "CommandExecWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", + "title": "CommandExecWriteResponse", + "type": "object" + }, "CommandExecutionOutputDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10720,6 +11022,27 @@ "title": "Item/plan/deltaNotification", "type": "object" }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", + "properties": { + "method": { + "enum": [ + "command/exec/outputDelta" + ], + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json new file mode 100644 index 0000000000..fff7e57d50 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecOutputDeltaNotification.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + } + }, + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", + "properties": { + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processId", + "stream" + ], + "title": "CommandExecOutputDeltaNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 08f1a9a15a..986ec4cc1e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,6 +5,28 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", @@ -179,14 +201,54 @@ ] } }, + "description": "Run a standalone command (argv vector) in the server sandbox without creating a thread or turn.\n\nThe final `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted.", "properties": { "command": { + "description": "Command argv vector. Empty arrays are rejected.", "items": { "type": "string" }, "type": "array" }, "cwd": { + "description": "Optional working directory. Defaults to the server cwd.", + "type": [ + "string", + "null" + ] + }, + "disableOutputCap": { + "description": "Disable stdout/stderr capture truncation for this request.\n\nCannot be combined with `outputBytesCap`.", + "type": "boolean" + }, + "disableTimeout": { + "description": "Disable the timeout entirely for this request.\n\nCannot be combined with `timeoutMs`.", + "type": "boolean" + }, + "env": { + "additionalProperties": { + "type": [ + "string", + "null" + ] + }, + "description": "Optional environment overrides merged into the server-computed environment.\n\nMatching names override inherited values. Set a key to `null` to unset an inherited variable.", + "type": [ + "object", + "null" + ] + }, + "outputBytesCap": { + "description": "Optional per-stream stdout/stderr capture cap in bytes.\n\nWhen omitted, the server default applies. Cannot be combined with `disableOutputCap`.", + "format": "uint", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "processId": { + "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ "string", "null" @@ -200,14 +262,39 @@ { "type": "null" } - ] + ], + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + }, + "size": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + }, + { + "type": "null" + } + ], + "description": "Optional initial PTY size in character cells. Only valid when `tty` is true." + }, + "streamStdin": { + "description": "Allow follow-up `command/exec/write` requests to write stdin bytes.\n\nRequires a client-supplied `processId`.", + "type": "boolean" + }, + "streamStdoutStderr": { + "description": "Stream stdout/stderr via `command/exec/outputDelta` notifications.\n\nStreamed bytes are not duplicated into the final response and require a client-supplied `processId`.", + "type": "boolean" }, "timeoutMs": { + "description": "Optional timeout in milliseconds.\n\nWhen omitted, the server default applies. Cannot be combined with `disableTimeout`.", "format": "int64", "type": [ "integer", "null" ] + }, + "tty": { + "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json new file mode 100644 index 0000000000..57d3b6a30c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeParams.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CommandExecTerminalSize": { + "description": "PTY size in character cells for `command/exec` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + } + }, + "description": "Resize a running PTY-backed `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "size": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecTerminalSize" + } + ], + "description": "New PTY size in character cells." + } + }, + "required": [ + "processId", + "size" + ], + "title": "CommandExecResizeParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json new file mode 100644 index 0000000000..def86b66d0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResizeResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/resize`.", + "title": "CommandExecResizeResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json index 8ca0f46b77..1bbc519238 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecResponse.json @@ -1,14 +1,18 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final buffered result for `command/exec`.", "properties": { "exitCode": { + "description": "Process exit code.", "format": "int32", "type": "integer" }, "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `command/exec/outputDelta`.", "type": "string" }, "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `command/exec/outputDelta`.", "type": "string" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json new file mode 100644 index 0000000000..1f84877051 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Terminate a running `command/exec` session.", + "properties": { + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecTerminateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json new file mode 100644 index 0000000000..59bdb0cb30 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecTerminateResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/terminate`.", + "title": "CommandExecTerminateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json new file mode 100644 index 0000000000..440f2410bf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteParams.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Write stdin bytes to a running `command/exec` session, close stdin, or both.", + "properties": { + "closeStdin": { + "description": "Close stdin after writing `deltaBase64`, if present.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Optional base64-encoded stdin bytes to write.", + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + } + }, + "required": [ + "processId" + ], + "title": "CommandExecWriteParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json new file mode 100644 index 0000000000..dff8301ebb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecWriteResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Empty success response for `command/exec/write`.", + "title": "CommandExecWriteResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index d402cf87b8..715a51d57e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -10,6 +10,9 @@ import type { RequestId } from "./RequestId"; import type { AppsListParams } from "./v2/AppsListParams"; import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams"; import type { CommandExecParams } from "./v2/CommandExecParams"; +import type { CommandExecResizeParams } from "./v2/CommandExecResizeParams"; +import type { CommandExecTerminateParams } from "./v2/CommandExecTerminateParams"; +import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; @@ -50,4 +53,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 8157ba2f48..daf23faa2c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -8,6 +8,7 @@ import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimit import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification"; import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification"; import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification"; +import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification"; import type { CommandExecutionOutputDeltaNotification } from "./v2/CommandExecutionOutputDeltaNotification"; import type { ConfigWarningNotification } from "./v2/ConfigWarningNotification"; import type { ContextCompactedNotification } from "./v2/ContextCompactedNotification"; @@ -49,4 +50,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts new file mode 100644 index 0000000000..9a4b728062 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts @@ -0,0 +1,30 @@ +// 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 { CommandExecOutputStream } from "./CommandExecOutputStream"; + +/** + * Base64-encoded output chunk emitted for a streaming `command/exec` request. + * + * These notifications are connection-scoped. If the originating connection + * closes, the server terminates the process. + */ +export type CommandExecOutputDeltaNotification = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * Output stream for this chunk. + */ +stream: CommandExecOutputStream, +/** + * Base64-encoded output bytes. + */ +deltaBase64: string, +/** + * `true` on the final streamed chunk for a stream when `outputBytesCap` + * truncated later output on that stream. + */ +capReached: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts new file mode 100644 index 0000000000..a8c5b66711 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputStream.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Stream label for `command/exec/outputDelta` notifications. + */ +export type CommandExecOutputStream = "stdout" | "stderr"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 847e19d693..2e132f7818 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -1,6 +1,97 @@ // 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 { CommandExecTerminalSize } from "./CommandExecTerminalSize"; import type { SandboxPolicy } from "./SandboxPolicy"; -export type CommandExecParams = { command: Array, timeoutMs?: number | null, cwd?: string | null, sandboxPolicy?: SandboxPolicy | null, }; +/** + * Run a standalone command (argv vector) in the server sandbox without + * creating a thread or turn. + * + * The final `command/exec` response is deferred until the process exits and is + * sent only after all `command/exec/outputDelta` notifications for that + * connection have been emitted. + */ +export type CommandExecParams = { +/** + * Command argv vector. Empty arrays are rejected. + */ +command: Array, +/** + * Optional client-supplied, connection-scoped process id. + * + * Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + * `command/exec/write`, `command/exec/resize`, and + * `command/exec/terminate` calls. When omitted, buffered execution gets an + * internal id that is not exposed to the client. + */ +processId?: string | null, +/** + * Enable PTY mode. + * + * This implies `streamStdin` and `streamStdoutStderr`. + */ +tty?: boolean, +/** + * Allow follow-up `command/exec/write` requests to write stdin bytes. + * + * Requires a client-supplied `processId`. + */ +streamStdin?: boolean, +/** + * Stream stdout/stderr via `command/exec/outputDelta` notifications. + * + * Streamed bytes are not duplicated into the final response and require a + * client-supplied `processId`. + */ +streamStdoutStderr?: boolean, +/** + * Optional per-stream stdout/stderr capture cap in bytes. + * + * When omitted, the server default applies. Cannot be combined with + * `disableOutputCap`. + */ +outputBytesCap?: number | null, +/** + * Disable stdout/stderr capture truncation for this request. + * + * Cannot be combined with `outputBytesCap`. + */ +disableOutputCap?: boolean, +/** + * Disable the timeout entirely for this request. + * + * Cannot be combined with `timeoutMs`. + */ +disableTimeout?: boolean, +/** + * Optional timeout in milliseconds. + * + * When omitted, the server default applies. Cannot be combined with + * `disableTimeout`. + */ +timeoutMs?: number | null, +/** + * Optional working directory. Defaults to the server cwd. + */ +cwd?: string | null, +/** + * Optional environment overrides merged into the server-computed + * environment. + * + * Matching names override inherited values. Set a key to `null` to unset + * an inherited variable. + */ +env?: { [key in string]?: string | null } | null, +/** + * Optional initial PTY size in character cells. Only valid when `tty` is + * true. + */ +size?: CommandExecTerminalSize | null, +/** + * Optional sandbox policy for this command. + * + * Uses the same shape as thread/turn execution sandbox configuration and + * defaults to the user's configured policy when omitted. + */ +sandboxPolicy?: SandboxPolicy | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts new file mode 100644 index 0000000000..dde1417c0a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeParams.ts @@ -0,0 +1,18 @@ +// 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 { CommandExecTerminalSize } from "./CommandExecTerminalSize"; + +/** + * Resize a running PTY-backed `command/exec` session. + */ +export type CommandExecResizeParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * New PTY size in character cells. + */ +size: CommandExecTerminalSize, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts new file mode 100644 index 0000000000..7b7f2be700 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResizeResponse.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Empty success response for `command/exec/resize`. + */ +export type CommandExecResizeResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts index 6887a3e3c2..c13efeffe6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecResponse.ts @@ -2,4 +2,23 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CommandExecResponse = { exitCode: number, stdout: string, stderr: string, }; +/** + * Final buffered result for `command/exec`. + */ +export type CommandExecResponse = { +/** + * Process exit code. + */ +exitCode: number, +/** + * Buffered stdout capture. + * + * Empty when stdout was streamed via `command/exec/outputDelta`. + */ +stdout: string, +/** + * Buffered stderr capture. + * + * Empty when stderr was streamed via `command/exec/outputDelta`. + */ +stderr: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts new file mode 100644 index 0000000000..5181b154ff --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminalSize.ts @@ -0,0 +1,16 @@ +// 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. + +/** + * PTY size in character cells for `command/exec` PTY sessions. + */ +export type CommandExecTerminalSize = { +/** + * Terminal height in character cells. + */ +rows: number, +/** + * Terminal width in character cells. + */ +cols: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts new file mode 100644 index 0000000000..8012d29e41 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateParams.ts @@ -0,0 +1,13 @@ +// 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. + +/** + * Terminate a running `command/exec` session. + */ +export type CommandExecTerminateParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts new file mode 100644 index 0000000000..dc6371fbdd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecTerminateResponse.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Empty success response for `command/exec/terminate`. + */ +export type CommandExecTerminateResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts new file mode 100644 index 0000000000..b4df50f1bf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteParams.ts @@ -0,0 +1,22 @@ +// 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. + +/** + * Write stdin bytes to a running `command/exec` session, close stdin, or + * both. + */ +export type CommandExecWriteParams = { +/** + * Client-supplied, connection-scoped `processId` from the original + * `command/exec` request. + */ +processId: string, +/** + * Optional base64-encoded stdin bytes to write. + */ +deltaBase64?: string | null, +/** + * Close stdin after writing `deltaBase64`, if present. + */ +closeStdin?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts new file mode 100644 index 0000000000..6dbbddf4dd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecWriteResponse.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Empty success response for `command/exec/write`. + */ +export type CommandExecWriteResponse = Record; 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 b2c1b21aa0..ac81e35a5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -38,8 +38,17 @@ export type { CollabAgentTool } from "./CollabAgentTool"; export type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus"; export type { CollaborationModeMask } from "./CollaborationModeMask"; export type { CommandAction } from "./CommandAction"; +export type { CommandExecOutputDeltaNotification } from "./CommandExecOutputDeltaNotification"; +export type { CommandExecOutputStream } from "./CommandExecOutputStream"; export type { CommandExecParams } from "./CommandExecParams"; +export type { CommandExecResizeParams } from "./CommandExecResizeParams"; +export type { CommandExecResizeResponse } from "./CommandExecResizeResponse"; export type { CommandExecResponse } from "./CommandExecResponse"; +export type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +export type { CommandExecTerminateParams } from "./CommandExecTerminateParams"; +export type { CommandExecTerminateResponse } from "./CommandExecTerminateResponse"; +export type { CommandExecWriteParams } from "./CommandExecWriteParams"; +export type { CommandExecWriteResponse } from "./CommandExecWriteResponse"; export type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision"; export type { CommandExecutionOutputDeltaNotification } from "./CommandExecutionOutputDeltaNotification"; export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 463084ae93..54c0425256 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -377,11 +377,26 @@ client_request_definitions! { response: v2::FeedbackUploadResponse, }, - /// Execute a command (argv vector) under the server's sandbox. + /// Execute a standalone command (argv vector) under the server's sandbox. OneOffCommandExec => "command/exec" { params: v2::CommandExecParams, response: v2::CommandExecResponse, }, + /// Write stdin bytes to a running `command/exec` session or close stdin. + CommandExecWrite => "command/exec/write" { + params: v2::CommandExecWriteParams, + response: v2::CommandExecWriteResponse, + }, + /// Terminate a running `command/exec` session by client-supplied `processId`. + CommandExecTerminate => "command/exec/terminate" { + params: v2::CommandExecTerminateParams, + response: v2::CommandExecTerminateResponse, + }, + /// Resize a running PTY-backed `command/exec` session by client-supplied `processId`. + CommandExecResize => "command/exec/resize" { + params: v2::CommandExecResizeParams, + response: v2::CommandExecResizeResponse, + }, ConfigRead => "config/read" { params: v2::ConfigReadParams, @@ -781,6 +796,8 @@ server_notification_definitions! { AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), /// EXPERIMENTAL - proposed plan streaming deltas for plan items. PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification), + /// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session. + CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/mappers.rs b/codex-rs/app-server-protocol/src/protocol/mappers.rs index f708c1fa85..93f12691be 100644 --- a/codex-rs/app-server-protocol/src/protocol/mappers.rs +++ b/codex-rs/app-server-protocol/src/protocol/mappers.rs @@ -1,14 +1,22 @@ use crate::protocol::v1; use crate::protocol::v2; - impl From for v2::CommandExecParams { fn from(value: v1::ExecOneOffCommandParams) -> Self { Self { command: value.command, + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, timeout_ms: value .timeout_ms .map(|timeout| i64::try_from(timeout).unwrap_or(60_000)), cwd: value.cwd, + env: None, + size: None, sandbox_policy: value.sandbox_policy.map(std::convert::Into::into), } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 5063f82182..6798b5bb90 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1805,29 +1805,184 @@ pub struct FeedbackUploadResponse { pub thread_id: String, } +/// PTY size in character cells for `command/exec` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Run a standalone command (argv vector) in the server sandbox without +/// creating a thread or turn. +/// +/// The final `command/exec` response is deferred until the process exits and is +/// sent only after all `command/exec/outputDelta` notifications for that +/// connection have been emitted. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecParams { + /// Command argv vector. Empty arrays are rejected. pub command: Vec, + /// Optional client-supplied, connection-scoped process id. + /// + /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + /// `command/exec/write`, `command/exec/resize`, and + /// `command/exec/terminate` calls. When omitted, buffered execution gets an + /// internal id that is not exposed to the client. + #[ts(optional = nullable)] + pub process_id: Option, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `command/exec/write` requests to write stdin bytes. + /// + /// Requires a client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `command/exec/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the final response and require a + /// client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableOutputCap`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option, + /// Disable stdout/stderr capture truncation for this request. + /// + /// Cannot be combined with `outputBytesCap`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_output_cap: bool, + /// Disable the timeout entirely for this request. + /// + /// Cannot be combined with `timeoutMs`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_timeout: bool, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableTimeout`. #[ts(type = "number | null")] #[ts(optional = nullable)] pub timeout_ms: Option, + /// Optional working directory. Defaults to the server cwd. #[ts(optional = nullable)] pub cwd: Option, + /// Optional environment overrides merged into the server-computed + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, + /// Optional sandbox policy for this command. + /// + /// Uses the same shape as thread/turn execution sandbox configuration and + /// defaults to the user's configured policy when omitted. #[ts(optional = nullable)] pub sandbox_policy: Option, } +/// Final buffered result for `command/exec`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CommandExecResponse { + /// Process exit code. pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `command/exec/outputDelta`. pub stdout: String, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `command/exec/outputDelta`. pub stderr: String, } +/// Write stdin bytes to a running `command/exec` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `command/exec/write`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteResponse {} + +/// Terminate a running `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, +} + +/// Empty success response for `command/exec/terminate`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateResponse {} + +/// Resize a running PTY-backed `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// New PTY size in character cells. + pub size: CommandExecTerminalSize, +} + +/// Empty success response for `command/exec/resize`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeResponse {} + +/// Stream label for `command/exec/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} + // === Threads, Turns, and Items === // Thread APIs #[derive( @@ -3989,6 +4144,26 @@ pub struct CommandExecutionOutputDeltaNotification { pub delta: String, } +/// Base64-encoded output chunk emitted for a streaming `command/exec` request. +/// +/// These notifications are connection-scoped. If the originating connection +/// closes, the server terminates the process. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecOutputDeltaNotification { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Output stream for this chunk. + pub stream: CommandExecOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// `true` on the final streamed chunk for a stream when `outputBytesCap` + /// truncated later output on that stream. + pub cap_reached: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4971,6 +5146,300 @@ mod tests { ); } + #[test] + fn command_exec_params_default_optional_streaming_flags() { + let params = serde_json::from_value::(json!({ + "command": ["ls", "-la"], + "timeoutMs": 1000, + "cwd": "/tmp" + })) + .expect("command/exec payload should deserialize"); + + assert_eq!( + params, + CommandExecParams { + command: vec!["ls".to_string(), "-la".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(1000), + cwd: Some(PathBuf::from("/tmp")), + env: None, + size: None, + sandbox_policy: None, + } + ); + } + + #[test] + fn command_exec_params_round_trips_disable_timeout() { + let params = CommandExecParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_id: Some("sleep-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processId": "sleep-1", + "disableTimeout": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "outputBytesCap": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_params_round_trips_disable_output_cap() { + let params = CommandExecParams { + command: vec!["yes".to_string()], + process_id: Some("yes-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["yes"], + "processId": "yes-1", + "streamStdoutStderr": true, + "outputBytesCap": null, + "disableOutputCap": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_params_round_trips_env_overrides_and_unsets() { + let params = CommandExecParams { + command: vec!["printenv".to_string(), "FOO".to_string()], + process_id: Some("env-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ("FOO".to_string(), Some("override".to_string())), + ("BAR".to_string(), Some("added".to_string())), + ("BAZ".to_string(), None), + ])), + size: None, + sandbox_policy: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["printenv", "FOO"], + "processId": "env-1", + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": { + "FOO": "override", + "BAR": "added", + "BAZ": null, + }, + "size": null, + "sandboxPolicy": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_write_round_trips_close_only_payload() { + let params = CommandExecWriteParams { + process_id: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); + assert_eq!( + value, + json!({ + "processId": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_terminate_round_trips() { + let params = CommandExecTerminateParams { + process_id: "proc-8".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); + assert_eq!( + value, + json!({ + "processId": "proc-8", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_params_round_trip_with_size() { + let params = CommandExecParams { + command: vec!["top".to_string()], + process_id: Some("pty-1".to_string()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 40, + cols: 120, + }), + sandbox_policy: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["top"], + "processId": "pty-1", + "tty": true, + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": { + "rows": 40, + "cols": 120, + }, + "sandboxPolicy": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_resize_round_trips() { + let params = CommandExecResizeParams { + process_id: "proc-9".to_string(), + size: CommandExecTerminalSize { + rows: 50, + cols: 160, + }, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); + assert_eq!( + value, + json!({ + "processId": "proc-9", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); + } + + #[test] + fn command_exec_output_delta_round_trips() { + let notification = CommandExecOutputDeltaNotification { + process_id: "proc-1".to_string(), + stream: CommandExecOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize command/exec/outputDelta notification"); + assert_eq!( + value, + json!({ + "processId": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); + } + #[test] fn sandbox_policy_round_trips_external_sandbox_network_access() { let v2_policy = SandboxPolicy::ExternalSandbox { diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 28579c6c8f..51931d6a80 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -96,6 +96,7 @@ const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[ "codex/event/item_started", "codex/event/item_completed", // v2 item deltas. + "command/exec/outputDelta", "item/agentMessage/delta", "item/plan/delta", "item/commandExecution/outputDelta", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index c8bcdfcea6..b2126f1947 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -18,12 +18,14 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } codex-otel = { workspace = true } codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } +codex-utils-pty = { workspace = true } codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } @@ -64,7 +66,6 @@ axum = { workspace = true, default-features = false, features = [ "json", "tokio", ] } -base64 = { workspace = true } core_test_support = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 28fbab856f..9329fd026c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -144,6 +144,10 @@ Example with notification opt-out: - `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). +- `command/exec/write` — write base64-decoded stdin bytes to a running `command/exec` session or close stdin; returns `{}`. +- `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`. +- `command/exec/terminate` — terminate a running `command/exec` session by `processId`; returns `{}`. +- `command/exec/outputDelta` — notification emitted for base64-encoded stdout/stderr chunks from a streaming `command/exec` session. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. @@ -161,7 +165,6 @@ Example with notification opt-out: - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. -- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `config/read` — fetch the effective config on disk after resolving config layering. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home). - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). @@ -613,11 +616,21 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin ```json { "method": "command/exec", "id": 32, "params": { "command": ["ls", "-la"], + "processId": "ls-1", // optional string; required for streaming and ability to terminate the process "cwd": "/Users/me/project", // optional; defaults to server cwd + "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names + "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true "sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config - "timeoutMs": 10000 // optional; ms timeout; defaults to server timeout + "outputBytesCap": 1048576, // optional; per-stream capture cap + "disableOutputCap": false, // optional; cannot be combined with outputBytesCap + "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout + "disableTimeout": false // optional; cannot be combined with timeoutMs +} } +{ "id": 32, "result": { + "exitCode": 0, + "stdout": "...", + "stderr": "" } } -{ "id": 32, "result": { "exitCode": 0, "stdout": "...", "stderr": "" } } ``` - For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. @@ -626,7 +639,70 @@ Notes: - Empty `command` arrays are rejected. - `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`). +- `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. +- When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. +- `disableOutputCap: true` disables stdout/stderr capture truncation for that `command/exec` request. It cannot be combined with `outputBytesCap`. +- `disableTimeout: true` disables the timeout entirely for that `command/exec` request. It cannot be combined with `timeoutMs`. +- `processId` is optional for buffered execution. When omitted, Codex generates an internal id for lifecycle tracking, but `tty`, `streamStdin`, and `streamStdoutStderr` must stay disabled and follow-up `command/exec/write` / `command/exec/terminate` calls are not available for that command. +- `size` is only valid when `tty: true`. It sets the initial PTY size in character cells. +- Buffered Windows sandbox execution accepts `processId` for correlation, but `command/exec/write` and `command/exec/terminate` are still unsupported for those requests. +- Buffered Windows sandbox execution also requires the default output cap; custom `outputBytesCap` and `disableOutputCap` are unsupported there. +- `tty`, `streamStdin`, and `streamStdoutStderr` are optional booleans. Legacy requests that omit them continue to use buffered execution. +- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`. +- `tty` and `streamStdin` do not disable the timeout on their own; omit `timeoutMs` to use the server default timeout, or set `disableTimeout: true` to keep the process alive until exit or explicit termination. +- `outputBytesCap` applies independently to `stdout` and `stderr`, and streamed bytes are not duplicated into the final response. +- The `command/exec` response is deferred until the process exits and is sent only after all `command/exec/outputDelta` notifications for that connection have been emitted. +- `command/exec/outputDelta` notifications are connection-scoped. If the originating connection closes, the server terminates the process. + +Streaming stdin/stdout uses base64 so PTY sessions can carry arbitrary bytes: + +```json +{ "method": "command/exec", "id": 33, "params": { + "command": ["bash", "-i"], + "processId": "bash-1", + "tty": true, + "outputBytesCap": 32768 +} } +{ "method": "command/exec/outputDelta", "params": { + "processId": "bash-1", + "stream": "stdout", + "deltaBase64": "YmFzaC00LjQkIA==", + "capReached": false +} } +{ "method": "command/exec/write", "id": 34, "params": { + "processId": "bash-1", + "deltaBase64": "cHdkCg==" +} } +{ "id": 34, "result": {} } +{ "method": "command/exec/write", "id": 35, "params": { + "processId": "bash-1", + "closeStdin": true +} } +{ "id": 35, "result": {} } +{ "method": "command/exec/resize", "id": 36, "params": { + "processId": "bash-1", + "size": { "rows": 48, "cols": 160 } +} } +{ "id": 36, "result": {} } +{ "method": "command/exec/terminate", "id": 37, "params": { + "processId": "bash-1" +} } +{ "id": 37, "result": {} } +{ "id": 33, "result": { + "exitCode": 137, + "stdout": "", + "stderr": "" +} } +``` + +- `command/exec/write` accepts either `deltaBase64`, `closeStdin`, or both. +- Clients may supply a connection-scoped string `processId` in `command/exec`; `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` only accept those client-supplied string ids. +- `command/exec/outputDelta.processId` is always the client-supplied string id from the original `command/exec` request. +- `command/exec/outputDelta.stream` is `stdout` or `stderr`. PTY mode multiplexes terminal output through `stdout`. +- `command/exec/outputDelta.capReached` is `true` on the final streamed chunk for a stream when `outputBytesCap` truncates that stream; later output on that stream is dropped. +- `command/exec.params.env` overrides the server-computed environment per key; set a key to `null` to unset an inherited variable. +- `command/exec/resize` is only supported for PTY-backed `command/exec` sessions. ## Events diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 523f3bbe98..d0e10a84ea 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,4 +1,6 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; +use crate::command_exec::CommandExecManager; +use crate::command_exec::StartCommandExecParams; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_PARAMS_ERROR_CODE; @@ -34,10 +36,12 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; -use codex_app_server_protocol::ExecOneOffCommandResponse; use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; @@ -193,6 +197,7 @@ use codex_core::connectors::filter_disallowed_connectors; use codex_core::connectors::merge_plugin_apps; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::error::CodexErr; +use codex_core::exec::ExecExpiration; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; use codex_core::features::FEATURES; @@ -264,6 +269,7 @@ use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_utils_json_to_toml::json_to_toml; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; @@ -282,6 +288,7 @@ use tokio::sync::Mutex; use tokio::sync::broadcast; use tokio::sync::oneshot; use tokio::sync::watch; +use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use tracing::error; use tracing::info; @@ -369,6 +376,7 @@ pub(crate) struct CodexMessageProcessor { pending_thread_unloads: Arc>>, thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, + command_exec_manager: CommandExecManager, pending_fuzzy_searches: Arc>>>, fuzzy_search_sessions: Arc>>, feedback: CodexFeedback, @@ -482,6 +490,7 @@ impl CodexMessageProcessor { pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), thread_state_manager: ThreadStateManager::new(), thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing), + command_exec_manager: CommandExecManager::default(), pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())), feedback, @@ -831,6 +840,18 @@ impl CodexMessageProcessor { self.exec_one_off_command(to_connection_request_id(request_id), params) .await; } + ClientRequest::CommandExecWrite { request_id, params } => { + self.command_exec_write(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::CommandExecResize { request_id, params } => { + self.command_exec_resize(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::CommandExecTerminate { request_id, params } => { + self.command_exec_terminate(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } | ClientRequest::ConfigBatchWrite { .. } => { @@ -1503,11 +1524,84 @@ impl CodexMessageProcessor { return; } - let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone()); - let env = create_env(&self.config.permissions.shell_environment_policy, None); - let timeout_ms = params - .timeout_ms - .and_then(|timeout_ms| u64::try_from(timeout_ms).ok()); + let CommandExecParams { + command, + process_id, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + disable_output_cap, + disable_timeout, + timeout_ms, + cwd, + env: env_overrides, + size, + sandbox_policy, + } = params; + + if size.is_some() && !tty { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "command/exec size requires tty: true".to_string(), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + + if disable_output_cap && output_bytes_cap.is_some() { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "command/exec cannot set both outputBytesCap and disableOutputCap" + .to_string(), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + + if disable_timeout && timeout_ms.is_some() { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "command/exec cannot set both timeoutMs and disableTimeout".to_string(), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + + let cwd = cwd.unwrap_or_else(|| self.config.cwd.clone()); + let mut env = create_env(&self.config.permissions.shell_environment_policy, None); + if let Some(env_overrides) = env_overrides { + for (key, value) in env_overrides { + match value { + Some(value) => { + env.insert(key, value); + } + None => { + env.remove(&key); + } + } + } + } + let timeout_ms = match timeout_ms { + Some(timeout_ms) => match u64::try_from(timeout_ms) { + Ok(timeout_ms) => Some(timeout_ms), + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: format!( + "command/exec timeoutMs must be non-negative, got {timeout_ms}" + ), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }, + None => None, + }; let managed_network_requirements_enabled = self.config.managed_network_requirements_enabled(); let started_network_proxy = match self.config.permissions.network.as_ref() { @@ -1535,10 +1629,23 @@ impl CodexMessageProcessor { None => None, }; let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let output_bytes_cap = if disable_output_cap { + None + } else { + Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP)) + }; + let expiration = if disable_timeout { + ExecExpiration::Cancellation(CancellationToken::new()) + } else { + match timeout_ms { + Some(timeout_ms) => timeout_ms.into(), + None => ExecExpiration::DefaultTimeout, + } + }; let exec_params = ExecParams { - command: params.command, + command, cwd, - expiration: timeout_ms.into(), + expiration, env, network: started_network_proxy .as_ref() @@ -1549,7 +1656,7 @@ impl CodexMessageProcessor { arg0: None, }; - let requested_policy = params.sandbox_policy.map(|policy| policy.to_core()); + let requested_policy = sandbox_policy.map(|policy| policy.to_core()); let effective_policy = match requested_policy { Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { Ok(()) => policy, @@ -1568,41 +1675,100 @@ impl CodexMessageProcessor { let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); - let request_for_task = request; + let request_for_task = request.clone(); let sandbox_cwd = self.config.cwd.clone(); let started_network_proxy_for_task = started_network_proxy; let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap); + let size = match size.map(crate::command_exec::terminal_size_from_protocol) { + Some(Ok(size)) => Some(size), + Some(Err(error)) => { + self.outgoing.send_error(request, error).await; + return; + } + None => None, + }; - tokio::spawn(async move { - let _started_network_proxy = started_network_proxy_for_task; - match codex_core::exec::process_exec_tool_call( - exec_params, - &effective_policy, - sandbox_cwd.as_path(), - &codex_linux_sandbox_exe, - use_linux_sandbox_bwrap, - None, - ) - .await - { - Ok(output) => { - let response = ExecOneOffCommandResponse { - exit_code: output.exit_code, - stdout: output.stdout.text, - stderr: output.stderr.text, - }; - outgoing.send_response(request_for_task, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("exec failed: {err}"), - data: None, - }; - outgoing.send_error(request_for_task, error).await; + match codex_core::exec::build_exec_request( + exec_params, + &effective_policy, + sandbox_cwd.as_path(), + &codex_linux_sandbox_exe, + use_linux_sandbox_bwrap, + ) { + Ok(exec_request) => { + if let Err(error) = self + .command_exec_manager + .start(StartCommandExecParams { + outgoing, + request_id: request_for_task, + process_id, + exec_request, + started_network_proxy: started_network_proxy_for_task, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + }) + .await + { + self.outgoing.send_error(request, error).await; } } - }); + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("exec failed: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + } + } + } + + async fn command_exec_write( + &self, + request_id: ConnectionRequestId, + params: CommandExecWriteParams, + ) { + match self + .command_exec_manager + .write(request_id.clone(), params) + .await + { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } + + async fn command_exec_resize( + &self, + request_id: ConnectionRequestId, + params: CommandExecResizeParams, + ) { + match self + .command_exec_manager + .resize(request_id.clone(), params) + .await + { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } + + async fn command_exec_terminate( + &self, + request_id: ConnectionRequestId, + params: CommandExecTerminateParams, + ) { + match self + .command_exec_manager + .terminate(request_id.clone(), params) + .await + { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } } async fn thread_start(&self, request_id: ConnectionRequestId, params: ThreadStartParams) { @@ -2872,6 +3038,9 @@ impl CodexMessageProcessor { } pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) { + self.command_exec_manager + .connection_closed(connection_id) + .await; self.thread_state_manager .remove_connection(connection_id) .await; diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs new file mode 100644 index 0000000000..85448ea963 --- /dev/null +++ b/codex-rs/app-server/src/command_exec.rs @@ -0,0 +1,1004 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicI64; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::CommandExecOutputDeltaNotification; +use codex_app_server_protocol::CommandExecOutputStream; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecResizeResponse; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::CommandExecTerminalSize; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecTerminateResponse; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::CommandExecWriteResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ServerNotification; +use codex_core::bytes_to_string_smart; +use codex_core::config::StartedNetworkProxy; +use codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; +use codex_core::exec::ExecExpiration; +use codex_core::exec::IO_DRAIN_TIMEOUT_MS; +use codex_core::exec::SandboxType; +use codex_core::sandboxing::ExecRequest; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use codex_utils_pty::ProcessHandle; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; + +use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::INVALID_PARAMS_ERROR_CODE; +use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; + +const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; + +#[derive(Clone)] +pub(crate) struct CommandExecManager { + sessions: Arc>>, + next_generated_process_id: Arc, +} + +impl Default for CommandExecManager { + fn default() -> Self { + Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + next_generated_process_id: Arc::new(AtomicI64::new(1)), + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ConnectionProcessId { + connection_id: ConnectionId, + process_id: InternalProcessId, +} + +#[derive(Clone)] +enum CommandExecSession { + Active { + control_tx: mpsc::Sender, + }, + UnsupportedWindowsSandbox, +} + +enum CommandControl { + Write { delta: Vec, close_stdin: bool }, + Resize { size: TerminalSize }, + Terminate, +} + +struct CommandControlRequest { + control: CommandControl, + response_tx: Option>>, +} + +pub(crate) struct StartCommandExecParams { + pub(crate) outgoing: Arc, + pub(crate) request_id: ConnectionRequestId, + pub(crate) process_id: Option, + pub(crate) exec_request: ExecRequest, + pub(crate) started_network_proxy: Option, + pub(crate) tty: bool, + pub(crate) stream_stdin: bool, + pub(crate) stream_stdout_stderr: bool, + pub(crate) output_bytes_cap: Option, + pub(crate) size: Option, +} + +struct RunCommandParams { + outgoing: Arc, + request_id: ConnectionRequestId, + process_id: Option, + spawned: SpawnedProcess, + control_rx: mpsc::Receiver, + stream_stdin: bool, + stream_stdout_stderr: bool, + expiration: ExecExpiration, + output_bytes_cap: Option, +} + +struct SpawnProcessOutputParams { + connection_id: ConnectionId, + process_id: Option, + output_rx: mpsc::Receiver>, + stdio_timeout_rx: watch::Receiver, + outgoing: Arc, + stream: CommandExecOutputStream, + stream_output: bool, + output_bytes_cap: Option, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum InternalProcessId { + Generated(i64), + Client(String), +} + +trait InternalProcessIdExt { + fn error_repr(&self) -> String; +} + +impl InternalProcessIdExt for InternalProcessId { + fn error_repr(&self) -> String { + match self { + Self::Generated(id) => id.to_string(), + Self::Client(id) => serde_json::to_string(id).unwrap_or_else(|_| format!("{id:?}")), + } + } +} + +impl CommandExecManager { + pub(crate) async fn start( + &self, + params: StartCommandExecParams, + ) -> Result<(), JSONRPCErrorError> { + let StartCommandExecParams { + outgoing, + request_id, + process_id, + exec_request, + started_network_proxy, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + } = params; + if process_id.is_none() && (tty || stream_stdin || stream_stdout_stderr) { + return Err(invalid_request( + "command/exec tty or streaming requires a client-supplied processId".to_string(), + )); + } + let process_id = process_id.map_or_else( + || { + InternalProcessId::Generated( + self.next_generated_process_id + .fetch_add(1, Ordering::Relaxed), + ) + }, + InternalProcessId::Client, + ); + let process_key = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + }; + + if matches!(exec_request.sandbox, SandboxType::WindowsRestrictedToken) { + if tty || stream_stdin || stream_stdout_stderr { + return Err(invalid_request( + "streaming command/exec is not supported with windows sandbox".to_string(), + )); + } + if output_bytes_cap != Some(DEFAULT_OUTPUT_BYTES_CAP) { + return Err(invalid_request( + "custom outputBytesCap is not supported with windows sandbox".to_string(), + )); + } + if let InternalProcessId::Client(_) = &process_id { + let mut sessions = self.sessions.lock().await; + if sessions.contains_key(&process_key) { + return Err(invalid_request(format!( + "duplicate active command/exec process id: {}", + process_key.process_id.error_repr(), + ))); + } + sessions.insert( + process_key.clone(), + CommandExecSession::UnsupportedWindowsSandbox, + ); + } + let sessions = Arc::clone(&self.sessions); + tokio::spawn(async move { + let _started_network_proxy = started_network_proxy; + match codex_core::sandboxing::execute_env(exec_request, None).await { + Ok(output) => { + outgoing + .send_response( + request_id, + CommandExecResponse { + exit_code: output.exit_code, + stdout: output.stdout.text, + stderr: output.stderr.text, + }, + ) + .await; + } + Err(err) => { + outgoing + .send_error(request_id, internal_error(format!("exec failed: {err}"))) + .await; + } + } + sessions.lock().await.remove(&process_key); + }); + return Ok(()); + } + + let ExecRequest { + command, + cwd, + env, + expiration, + sandbox: _sandbox, + arg0, + .. + } = exec_request; + + let stream_stdin = tty || stream_stdin; + let stream_stdout_stderr = tty || stream_stdout_stderr; + let (control_tx, control_rx) = mpsc::channel(32); + let notification_process_id = match &process_id { + InternalProcessId::Generated(_) => None, + InternalProcessId::Client(process_id) => Some(process_id.clone()), + }; + + let sessions = Arc::clone(&self.sessions); + let (program, args) = command + .split_first() + .ok_or_else(|| invalid_request("command must not be empty".to_string()))?; + { + let mut sessions = self.sessions.lock().await; + if sessions.contains_key(&process_key) { + return Err(invalid_request(format!( + "duplicate active command/exec process id: {}", + process_key.process_id.error_repr(), + ))); + } + sessions.insert( + process_key.clone(), + CommandExecSession::Active { control_tx }, + ); + } + let spawned = if tty { + codex_utils_pty::spawn_pty_process( + program, + args, + cwd.as_path(), + &env, + &arg0, + size.unwrap_or_default(), + ) + .await + } else if stream_stdin { + codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await + } else { + codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0) + .await + }; + let spawned = match spawned { + Ok(spawned) => spawned, + Err(err) => { + self.sessions.lock().await.remove(&process_key); + return Err(internal_error(format!("failed to spawn command: {err}"))); + } + }; + tokio::spawn(async move { + let _started_network_proxy = started_network_proxy; + run_command(RunCommandParams { + outgoing, + request_id: request_id.clone(), + process_id: notification_process_id, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + }) + .await; + sessions.lock().await.remove(&process_key); + }); + Ok(()) + } + + pub(crate) async fn write( + &self, + request_id: ConnectionRequestId, + params: CommandExecWriteParams, + ) -> Result { + if params.delta_base64.is_none() && !params.close_stdin { + return Err(invalid_params( + "command/exec/write requires deltaBase64 or closeStdin".to_string(), + )); + } + + let delta = match params.delta_base64 { + Some(delta_base64) => STANDARD + .decode(delta_base64) + .map_err(|err| invalid_params(format!("invalid deltaBase64: {err}")))?, + None => Vec::new(), + }; + + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control( + target_process_id, + CommandControl::Write { + delta, + close_stdin: params.close_stdin, + }, + ) + .await?; + + Ok(CommandExecWriteResponse {}) + } + + pub(crate) async fn terminate( + &self, + request_id: ConnectionRequestId, + params: CommandExecTerminateParams, + ) -> Result { + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control(target_process_id, CommandControl::Terminate) + .await?; + Ok(CommandExecTerminateResponse {}) + } + + pub(crate) async fn resize( + &self, + request_id: ConnectionRequestId, + params: CommandExecResizeParams, + ) -> Result { + let target_process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client(params.process_id), + }; + self.send_control( + target_process_id, + CommandControl::Resize { + size: terminal_size_from_protocol(params.size)?, + }, + ) + .await?; + Ok(CommandExecResizeResponse {}) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let controls = { + let mut sessions = self.sessions.lock().await; + let process_ids = sessions + .keys() + .filter(|process_id| process_id.connection_id == connection_id) + .cloned() + .collect::>(); + let mut controls = Vec::with_capacity(process_ids.len()); + for process_id in process_ids { + if let Some(control) = sessions.remove(&process_id) { + controls.push(control); + } + } + controls + }; + + for control in controls { + if let CommandExecSession::Active { control_tx } = control { + let _ = control_tx + .send(CommandControlRequest { + control: CommandControl::Terminate, + response_tx: None, + }) + .await; + } + } + } + + async fn send_control( + &self, + process_id: ConnectionProcessId, + control: CommandControl, + ) -> Result<(), JSONRPCErrorError> { + let session = { + self.sessions + .lock() + .await + .get(&process_id) + .cloned() + .ok_or_else(|| { + invalid_request(format!( + "no active command/exec for process id {}", + process_id.process_id.error_repr(), + )) + })? + }; + let CommandExecSession::Active { control_tx } = session else { + return Err(invalid_request( + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes".to_string(), + )); + }; + let (response_tx, response_rx) = oneshot::channel(); + let request = CommandControlRequest { + control, + response_tx: Some(response_tx), + }; + control_tx + .send(request) + .await + .map_err(|_| command_no_longer_running_error(&process_id.process_id))?; + response_rx + .await + .map_err(|_| command_no_longer_running_error(&process_id.process_id))? + } +} + +async fn run_command(params: RunCommandParams) { + let RunCommandParams { + outgoing, + request_id, + process_id, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + } = params; + let mut control_rx = control_rx; + let mut control_open = true; + let expiration = async { + match expiration { + ExecExpiration::Timeout(duration) => tokio::time::sleep(duration).await, + ExecExpiration::DefaultTimeout => { + tokio::time::sleep(Duration::from_millis(DEFAULT_EXEC_COMMAND_TIMEOUT_MS)).await; + } + ExecExpiration::Cancellation(cancel) => { + cancel.cancelled().await; + } + } + }; + tokio::pin!(expiration); + let SpawnedProcess { + session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + tokio::pin!(exit_rx); + let mut timed_out = false; + let (stdio_timeout_tx, stdio_timeout_rx) = watch::channel(false); + + let stdout_handle = spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + output_rx: stdout_rx, + stdio_timeout_rx: stdio_timeout_rx.clone(), + outgoing: Arc::clone(&outgoing), + stream: CommandExecOutputStream::Stdout, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + let stderr_handle = spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_id, + output_rx: stderr_rx, + stdio_timeout_rx, + outgoing: Arc::clone(&outgoing), + stream: CommandExecOutputStream::Stderr, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + + let exit_code = loop { + tokio::select! { + control = control_rx.recv(), if control_open => { + match control { + Some(CommandControlRequest { control, response_tx }) => { + let result = match control { + CommandControl::Write { delta, close_stdin } => { + handle_process_write( + &session, + stream_stdin, + delta, + close_stdin, + ).await + } + CommandControl::Resize { size } => { + handle_process_resize(&session, size) + } + CommandControl::Terminate => { + session.request_terminate(); + Ok(()) + } + }; + if let Some(response_tx) = response_tx { + let _ = response_tx.send(result); + } + }, + None => { + control_open = false; + session.request_terminate(); + } + } + } + _ = &mut expiration, if !timed_out => { + timed_out = true; + session.request_terminate(); + } + exit = &mut exit_rx => { + if timed_out { + break EXEC_TIMEOUT_EXIT_CODE; + } else { + break exit.unwrap_or(-1); + } + } + } + }; + + let timeout_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(IO_DRAIN_TIMEOUT_MS)).await; + let _ = stdio_timeout_tx.send(true); + }); + + let stdout = stdout_handle.await.unwrap_or_default(); + let stderr = stderr_handle.await.unwrap_or_default(); + timeout_handle.abort(); + + outgoing + .send_response( + request_id, + CommandExecResponse { + exit_code, + stdout, + stderr, + }, + ) + .await; +} + +fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHandle { + let SpawnProcessOutputParams { + connection_id, + process_id, + mut output_rx, + mut stdio_timeout_rx, + outgoing, + stream, + stream_output, + output_bytes_cap, + } = params; + tokio::spawn(async move { + let mut buffer: Vec = Vec::new(); + let mut observed_num_bytes = 0usize; + loop { + let chunk = tokio::select! { + chunk = output_rx.recv() => match chunk { + Some(chunk) => chunk, + None => break, + }, + _ = stdio_timeout_rx.wait_for(|&v| v) => break, + }; + let capped_chunk = match output_bytes_cap { + Some(output_bytes_cap) => { + let capped_chunk_len = output_bytes_cap + .saturating_sub(observed_num_bytes) + .min(chunk.len()); + observed_num_bytes += capped_chunk_len; + &chunk[0..capped_chunk_len] + } + None => chunk.as_slice(), + }; + let cap_reached = Some(observed_num_bytes) == output_bytes_cap; + if let (true, Some(process_id)) = (stream_output, process_id.as_ref()) { + outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::CommandExecOutputDelta( + CommandExecOutputDeltaNotification { + process_id: process_id.clone(), + stream, + delta_base64: STANDARD.encode(capped_chunk), + cap_reached, + }, + ), + ) + .await; + } else if !stream_output { + buffer.extend_from_slice(capped_chunk); + } + if cap_reached { + break; + } + } + bytes_to_string_smart(&buffer) + }) +} + +async fn handle_process_write( + session: &ProcessHandle, + stream_stdin: bool, + delta: Vec, + close_stdin: bool, +) -> Result<(), JSONRPCErrorError> { + if !stream_stdin { + return Err(invalid_request( + "stdin streaming is not enabled for this command/exec".to_string(), + )); + } + if !delta.is_empty() { + session + .writer_sender() + .send(delta) + .await + .map_err(|_| invalid_request("stdin is already closed".to_string()))?; + } + if close_stdin { + session.close_stdin(); + } + Ok(()) +} + +fn handle_process_resize( + session: &ProcessHandle, + size: TerminalSize, +) -> Result<(), JSONRPCErrorError> { + session + .resize(size) + .map_err(|err| invalid_request(format!("failed to resize PTY: {err}"))) +} + +pub(crate) fn terminal_size_from_protocol( + size: CommandExecTerminalSize, +) -> Result { + if size.rows == 0 || size.cols == 0 { + return Err(invalid_params( + "command/exec size rows and cols must be greater than 0".to_string(), + )); + } + Ok(TerminalSize { + rows: size.rows, + cols: size.cols, + }) +} + +fn command_no_longer_running_error(process_id: &InternalProcessId) -> JSONRPCErrorError { + invalid_request(format!( + "command/exec {} is no longer running", + process_id.error_repr(), + )) +} + +fn invalid_request(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + } +} + +fn invalid_params(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message, + data: None, + } +} + +fn internal_error(message: String) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message, + data: None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use codex_protocol::config_types::WindowsSandboxLevel; + use codex_protocol::protocol::ReadOnlyAccess; + use codex_protocol::protocol::SandboxPolicy; + use pretty_assertions::assert_eq; + #[cfg(not(target_os = "windows"))] + use tokio::time::Duration; + #[cfg(not(target_os = "windows"))] + use tokio::time::timeout; + #[cfg(not(target_os = "windows"))] + use tokio_util::sync::CancellationToken; + + use super::*; + #[cfg(not(target_os = "windows"))] + use crate::outgoing_message::OutgoingEnvelope; + #[cfg(not(target_os = "windows"))] + use crate::outgoing_message::OutgoingMessage; + + fn windows_sandbox_exec_request() -> ExecRequest { + ExecRequest { + command: vec!["cmd".to_string()], + cwd: PathBuf::from("."), + env: HashMap::new(), + network: None, + expiration: ExecExpiration::DefaultTimeout, + sandbox: SandboxType::WindowsRestrictedToken, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, + sandbox_policy: SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }, + justification: None, + arg0: None, + } + } + + #[tokio::test] + async fn windows_sandbox_streaming_exec_is_rejected() { + let (tx, _rx) = mpsc::channel(1); + let manager = CommandExecManager::default(); + let err = manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new(tx)), + request_id: ConnectionRequestId { + connection_id: ConnectionId(1), + request_id: codex_app_server_protocol::RequestId::Integer(42), + }, + process_id: Some("proc-42".to_string()), + exec_request: windows_sandbox_exec_request(), + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + size: None, + }) + .await + .expect_err("streaming windows sandbox exec should be rejected"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "streaming command/exec is not supported with windows sandbox" + ); + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn windows_sandbox_non_streaming_exec_uses_execution_path() { + let (tx, mut rx) = mpsc::channel(1); + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(7), + request_id: codex_app_server_protocol::RequestId::Integer(99), + }; + + manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new(tx)), + request_id: request_id.clone(), + process_id: Some("proc-99".to_string()), + exec_request: windows_sandbox_exec_request(), + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(DEFAULT_OUTPUT_BYTES_CAP), + size: None, + }) + .await + .expect("non-streaming windows sandbox exec should start"); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for outgoing message") + .expect("channel closed before outgoing message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + } = envelope + else { + panic!("expected connection-scoped outgoing message"); + }; + assert_eq!(connection_id, request_id.connection_id); + let OutgoingMessage::Error(error) = message else { + panic!("expected execution failure to be reported as an error"); + }; + assert_eq!(error.id, request_id.request_id); + assert!(error.error.message.starts_with("exec failed:")); + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn cancellation_expiration_keeps_process_alive_until_terminated() { + let (tx, mut rx) = mpsc::channel(4); + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(8), + request_id: codex_app_server_protocol::RequestId::Integer(100), + }; + + manager + .start(StartCommandExecParams { + outgoing: Arc::new(OutgoingMessageSender::new(tx)), + request_id: request_id.clone(), + process_id: Some("proc-100".to_string()), + exec_request: ExecRequest { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + cwd: PathBuf::from("."), + env: HashMap::new(), + network: None, + expiration: ExecExpiration::Cancellation(CancellationToken::new()), + sandbox: SandboxType::None, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, + sandbox_policy: SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }, + justification: None, + arg0: None, + }, + started_network_proxy: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(DEFAULT_OUTPUT_BYTES_CAP), + size: None, + }) + .await + .expect("cancellation-based exec should start"); + + assert!( + timeout(Duration::from_millis(250), rx.recv()) + .await + .is_err(), + "command/exec should remain active until explicit termination", + ); + + manager + .terminate( + request_id.clone(), + CommandExecTerminateParams { + process_id: "proc-100".to_string(), + }, + ) + .await + .expect("terminate should succeed"); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timed out waiting for outgoing message") + .expect("channel closed before outgoing message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + } = envelope + else { + panic!("expected connection-scoped outgoing message"); + }; + assert_eq!(connection_id, request_id.connection_id); + let OutgoingMessage::Response(response) = message else { + panic!("expected execution response after termination"); + }; + assert_eq!(response.id, request_id.request_id); + let response: CommandExecResponse = + serde_json::from_value(response.result).expect("deserialize command/exec response"); + assert_ne!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + // The deferred response now drains any already-emitted stderr before + // replying, so shell startup noise is allowed here. + } + + #[tokio::test] + async fn windows_sandbox_process_ids_reject_write_requests() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(11), + request_id: codex_app_server_protocol::RequestId::Integer(1), + }; + let process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client("proc-11".to_string()), + }; + manager + .sessions + .lock() + .await + .insert(process_id, CommandExecSession::UnsupportedWindowsSandbox); + + let err = manager + .write( + request_id, + CommandExecWriteParams { + process_id: "proc-11".to_string(), + delta_base64: Some(STANDARD.encode("hello")), + close_stdin: false, + }, + ) + .await + .expect_err("windows sandbox process ids should reject command/exec/write"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes" + ); + } + + #[tokio::test] + async fn windows_sandbox_process_ids_reject_terminate_requests() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(12), + request_id: codex_app_server_protocol::RequestId::Integer(2), + }; + let process_id = ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: InternalProcessId::Client("proc-12".to_string()), + }; + manager + .sessions + .lock() + .await + .insert(process_id, CommandExecSession::UnsupportedWindowsSandbox); + + let err = manager + .terminate( + request_id, + CommandExecTerminateParams { + process_id: "proc-12".to_string(), + }, + ) + .await + .expect_err("windows sandbox process ids should reject command/exec/terminate"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + err.message, + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes" + ); + } + + #[tokio::test] + async fn dropped_control_request_is_reported_as_not_running() { + let manager = CommandExecManager::default(); + let request_id = ConnectionRequestId { + connection_id: ConnectionId(13), + request_id: codex_app_server_protocol::RequestId::Integer(3), + }; + let process_id = InternalProcessId::Client("proc-13".to_string()); + let (control_tx, mut control_rx) = mpsc::channel(1); + manager.sessions.lock().await.insert( + ConnectionProcessId { + connection_id: request_id.connection_id, + process_id: process_id.clone(), + }, + CommandExecSession::Active { control_tx }, + ); + + tokio::spawn(async move { + let _request = control_rx + .recv() + .await + .expect("expected queued control request"); + }); + + let err = manager + .terminate( + request_id, + CommandExecTerminateParams { + process_id: "proc-13".to_string(), + }, + ) + .await + .expect_err("dropped control request should be treated as not running"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(err.message, "command/exec \"proc-13\" is no longer running"); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 3505f432d5..b08bbbb983 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -58,6 +58,7 @@ use tracing_subscriber::util::SubscriberInitExt; mod app_server_tracing; mod bespoke_event_handling; mod codex_message_processor; +mod command_exec; mod config_api; mod dynamic_tools; mod error_code; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 58514f39f0..a52338b2a4 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -16,6 +16,10 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; @@ -494,6 +498,42 @@ impl McpProcess { self.send_request("turn/start", params).await } + /// Send a `command/exec` JSON-RPC request (v2). + pub async fn send_command_exec_request( + &mut self, + params: CommandExecParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec", params).await + } + + /// Send a `command/exec/write` JSON-RPC request (v2). + pub async fn send_command_exec_write_request( + &mut self, + params: CommandExecWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/write", params).await + } + + /// Send a `command/exec/resize` JSON-RPC request (v2). + pub async fn send_command_exec_resize_request( + &mut self, + params: CommandExecResizeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/resize", params).await + } + + /// Send a `command/exec/terminate` JSON-RPC request (v2). + pub async fn send_command_exec_terminate_request( + &mut self, + params: CommandExecTerminateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("command/exec/terminate", params).await + } + /// Send a `turn/interrupt` JSON-RPC request (v2). pub async fn send_turn_interrupt_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs new file mode 100644 index 0000000000..562e398925 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -0,0 +1,880 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::CommandExecOutputDeltaNotification; +use codex_app_server_protocol::CommandExecOutputStream; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::CommandExecTerminalSize; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; + +use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT; +use super::connection_handling_websocket::assert_no_message; +use super::connection_handling_websocket::connect_websocket; +use super::connection_handling_websocket::create_config_toml; +use super::connection_handling_websocket::read_jsonrpc_message; +use super::connection_handling_websocket::reserve_local_addr; +use super::connection_handling_websocket::send_initialize_request; +use super::connection_handling_websocket::send_request; +use super::connection_handling_websocket::spawn_websocket_server; + +#[tokio::test] +async fn command_exec_without_streams_can_be_terminated() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "sleep-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + let terminate_request_id = mcp + .send_command_exec_terminate_request(CommandExecTerminateParams { process_id }) + .await?; + + let terminate_response = mcp + .read_stream_until_response_message(RequestId::Integer(terminate_request_id)) + .await?; + assert_eq!(terminate_response.result, serde_json::json!({})); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_ne!( + response.exit_code, 0, + "terminated command should not succeed" + ); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'legacy-out'; printf 'legacy-err' >&2".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "legacy-out".to_string(), + stderr: "legacy-err".to_string(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_env_overrides_merge_with_server_environment_and_support_unset() -> Result<()> +{ + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[("COMMAND_EXEC_BASELINE", Some("server"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "/bin/sh".to_string(), + "-lc".to_string(), + "printf '%s|%s|%s|%s' \"$COMMAND_EXEC_BASELINE\" \"$COMMAND_EXEC_EXTRA\" \"${RUST_LOG-unset}\" \"$CODEX_HOME\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ( + "COMMAND_EXEC_BASELINE".to_string(), + Some("request".to_string()), + ), + ("COMMAND_EXEC_EXTRA".to_string(), Some("added".to_string())), + ("RUST_LOG".to_string(), None), + ])), + size: None, + sandbox_policy: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: format!("request|added|unset|{}", codex_home.path().display()), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("invalid-timeout-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: Some(1_000), + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec cannot set both timeoutMs and disableTimeout" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("invalid-cap-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(1024), + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec cannot set both outputBytesCap and disableOutputCap" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + process_id: Some("negative-timeout-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(-1), + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec timeoutMs must be non-negative, got -1" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "cat".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "command/exec tty or streaming requires a client-supplied processId" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'abcdef'; printf 'uvwxyz' >&2".to_string(), + ], + process_id: Some("cap-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: Some(5), + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "abcde".to_string(), + stderr: "uvwxy".to_string(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "stream-cap-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'abcdefghij'; sleep 30".to_string(), + ], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: Some(5), + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let delta = read_command_exec_delta(&mut mcp).await?; + assert_eq!(delta.process_id, process_id.as_str()); + assert_eq!(delta.stream, CommandExecOutputStream::Stdout); + assert_eq!(STANDARD.decode(&delta.delta_base64)?, b"abcde"); + assert!(delta.cap_reached); + let terminate_request_id = mcp + .send_command_exec_terminate_request(CommandExecTerminateParams { + process_id: process_id.clone(), + }) + .await?; + let terminate_response = mcp + .read_stream_until_response_message(RequestId::Integer(terminate_request_id)) + .await?; + assert_eq!(terminate_response.result, serde_json::json!({})); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_ne!( + response.exit_code, 0, + "terminated command should not succeed" + ); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "pipe-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'out-start\\n'; printf 'err-start\\n' >&2; IFS= read line; printf 'out:%s\\n' \"$line\"; printf 'err:%s\\n' \"$line\" >&2".to_string(), + ], + process_id: Some(process_id.clone()), + tty: false, + stream_stdin: true, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let first_stdout = read_command_exec_delta(&mut mcp).await?; + let first_stderr = read_command_exec_delta(&mut mcp).await?; + let seen = [first_stdout, first_stderr]; + assert!( + seen.iter() + .all(|delta| delta.process_id == process_id.as_str()) + ); + assert!(seen.iter().any(|delta| { + delta.stream == CommandExecOutputStream::Stdout + && delta.delta_base64 == STANDARD.encode("out-start\n") + })); + assert!(seen.iter().any(|delta| { + delta.stream == CommandExecOutputStream::Stderr + && delta.delta_base64 == STANDARD.encode("err-start\n") + })); + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("hello\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + let next_delta = read_command_exec_delta(&mut mcp).await?; + let final_delta = read_command_exec_delta(&mut mcp).await?; + let seen = [next_delta, final_delta]; + assert!( + seen.iter() + .all(|delta| delta.process_id == process_id.as_str()) + ); + assert!(seen.iter().any(|delta| { + delta.stream == CommandExecOutputStream::Stdout + && delta.delta_base64 == STANDARD.encode("out:hello\n") + })); + assert!(seen.iter().any(|delta| { + delta.stream == CommandExecOutputStream::Stderr + && delta.delta_base64 == STANDARD.encode("err:hello\n") + })); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "tty-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "stty -echo; if [ -t 0 ]; then printf 'tty\\n'; else printf 'notty\\n'; fi; IFS= read line; printf 'echo:%s\\n' \"$line\"".to_string(), + ], + process_id: Some(process_id.clone()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + }) + .await?; + + let started_text = read_command_exec_output_until_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "tty\n", + ) + .await?; + assert!( + started_text.contains("tty\n"), + "expected TTY startup output, got {started_text:?}" + ); + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("world\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + let echoed_text = read_command_exec_output_until_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "echo:world\n", + ) + .await?; + assert!( + echoed_text.contains("echo:world\n"), + "expected TTY echo output, got {echoed_text:?}" + ); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_id = "tty-size-1".to_string(); + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\"".to_string(), + ], + process_id: Some(process_id.clone()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 31, + cols: 101, + }), + sandbox_policy: None, + }) + .await?; + + let started_text = read_command_exec_output_until_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "start:31 101\n", + ) + .await?; + assert!( + started_text.contains("start:31 101\n"), + "unexpected initial size output: {started_text:?}" + ); + + let resize_request_id = mcp + .send_command_exec_resize_request(CommandExecResizeParams { + process_id: process_id.clone(), + size: CommandExecTerminalSize { + rows: 45, + cols: 132, + }, + }) + .await?; + let resize_response = mcp + .read_stream_until_response_message(RequestId::Integer(resize_request_id)) + .await?; + assert_eq!(resize_response.result, serde_json::json!({})); + + let write_request_id = mcp + .send_command_exec_write_request(CommandExecWriteParams { + process_id: process_id.clone(), + delta_base64: Some(STANDARD.encode("go\n")), + close_stdin: true, + }) + .await?; + let write_response = mcp + .read_stream_until_response_message(RequestId::Integer(write_request_id)) + .await?; + assert_eq!(write_response.result, serde_json::json!({})); + + let resized_text = read_command_exec_output_until_contains( + &mut mcp, + process_id.as_str(), + CommandExecOutputStream::Stdout, + "after:45 132\n", + ) + .await?; + assert!( + resized_text.contains("after:45 132\n"), + "unexpected resized output: {resized_text:?}" + ); + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!(response.exit_code, 0); + assert_eq!(response.stdout, ""); + assert_eq!(response.stderr, ""); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminates_process() +-> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let bind_addr = reserve_local_addr()?; + let mut process = spawn_websocket_server(codex_home.path(), bind_addr).await?; + + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + + send_initialize_request(&mut ws1, 1, "ws_client_one").await?; + read_initialize_response(&mut ws1, 1).await?; + send_initialize_request(&mut ws2, 2, "ws_client_two").await?; + read_initialize_response(&mut ws2, 2).await?; + + send_request( + &mut ws1, + "command/exec", + 101, + Some(serde_json::json!({ + "command": ["sh", "-lc", "printf 'ready\\n%s\\n' $$; sleep 30"], + "processId": "shared-process", + "streamStdoutStderr": true, + })), + ) + .await?; + + let delta = read_command_exec_delta_ws(&mut ws1).await?; + assert_eq!(delta.process_id, "shared-process"); + assert_eq!(delta.stream, CommandExecOutputStream::Stdout); + let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?; + let pid = delta_text + .lines() + .last() + .context("delta should include shell pid")? + .parse::() + .context("parse shell pid")?; + + send_request( + &mut ws2, + "command/exec/terminate", + 102, + Some(serde_json::json!({ + "processId": "shared-process", + })), + ) + .await?; + + let terminate_error = loop { + let message = read_jsonrpc_message(&mut ws2).await?; + if let JSONRPCMessage::Error(error) = message + && error.id == RequestId::Integer(102) + { + break error; + } + }; + assert_eq!( + terminate_error.error.message, + "no active command/exec for process id \"shared-process\"" + ); + assert!(process_is_alive(pid)?); + + assert_no_message(&mut ws2, Duration::from_millis(250)).await?; + ws1.close(None).await?; + + wait_for_process_exit(pid).await?; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +async fn read_command_exec_delta( + mcp: &mut McpProcess, +) -> Result { + let notification = mcp + .read_stream_until_notification_message("command/exec/outputDelta") + .await?; + decode_delta_notification(notification) +} + +async fn read_command_exec_output_until_contains( + mcp: &mut McpProcess, + process_id: &str, + stream: CommandExecOutputStream, + expected: &str, +) -> Result { + let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; + let mut collected = String::new(); + + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let delta = timeout(remaining, read_command_exec_delta(mcp)) + .await + .with_context(|| { + format!( + "timed out waiting for {expected:?} in command/exec output for {process_id}; collected {collected:?}" + ) + })??; + assert_eq!(delta.process_id, process_id); + assert_eq!(delta.stream, stream); + + let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?; + collected.push_str(&delta_text.replace('\r', "")); + if collected.contains(expected) { + return Ok(collected); + } + } +} + +async fn read_command_exec_delta_ws( + stream: &mut super::connection_handling_websocket::WsClient, +) -> Result { + loop { + let message = read_jsonrpc_message(stream).await?; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + if notification.method == "command/exec/outputDelta" { + return decode_delta_notification(notification); + } + } +} + +fn decode_delta_notification( + notification: JSONRPCNotification, +) -> Result { + let params = notification + .params + .context("command/exec/outputDelta notification should include params")?; + serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") +} + +async fn read_initialize_response( + stream: &mut super::connection_handling_websocket::WsClient, + request_id: i64, +) -> Result<()> { + loop { + let message = read_jsonrpc_message(stream).await?; + if let JSONRPCMessage::Response(response) = message + && response.id == RequestId::Integer(request_id) + { + return Ok(()); + } + } +} + +async fn wait_for_process_exit(pid: u32) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if !process_is_alive(pid)? { + return Ok(()); + } + if Instant::now() >= deadline { + anyhow::bail!("process {pid} was still alive after websocket disconnect"); + } + sleep(Duration::from_millis(50)).await; + } +} + +fn process_is_alive(pid: u32) -> Result { + let status = std::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .status() + .context("spawn kill -0")?; + Ok(status.success()) +} diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 26fffd8a13..349e92df3c 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -265,7 +265,7 @@ async fn read_error_for_id(stream: &mut WsClient, id: i64) -> Result Result { +pub(super) async fn read_jsonrpc_message(stream: &mut WsClient) -> Result { loop { let frame = timeout(DEFAULT_READ_TIMEOUT, stream.next()) .await diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index f85849250d..327b6a04d8 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -2,6 +2,8 @@ mod account; mod analytics; mod app_list; mod collaboration_mode_list; +#[cfg(unix)] +mod command_exec; mod compaction; mod config_rpc; mod connection_handling_websocket; diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 8779b2e1c3..b88b498349 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -34,6 +34,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; use codex_network_proxy::NetworkProxy; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000; @@ -53,12 +54,21 @@ const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB /// /// This mirrors unified exec's output cap so a single runaway command cannot /// OOM the process by dumping huge amounts of data to stdout/stderr. -const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB +const EXEC_OUTPUT_MAX_BYTES: usize = DEFAULT_OUTPUT_BYTES_CAP; /// Limit the number of ExecCommandOutputDelta events emitted per exec call. /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; +// Wait for the stdout/stderr collection tasks but guard against them +// hanging forever. In the normal case, both pipes are closed once the child +// terminates so the tasks exit quickly. However, if the child process +// spawned grandchildren that inherited its stdout/stderr file descriptors +// those pipes may stay open after we `kill` the direct child on timeout. +// That would cause the `read_capped` tasks to block on `read()` +// indefinitely, effectively hanging the whole agent. +pub const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; // 2 s should be plenty for local pipes + #[derive(Debug)] pub struct ExecParams { pub command: Vec, @@ -157,6 +167,27 @@ pub async fn process_exec_tool_call( use_linux_sandbox_bwrap: bool, stdout_stream: Option, ) -> Result { + let exec_req = build_exec_request( + params, + sandbox_policy, + sandbox_cwd, + codex_linux_sandbox_exe, + use_linux_sandbox_bwrap, + )?; + + // Route through the sandboxing module for a single, unified execution path. + crate::sandboxing::execute_env(exec_req, stdout_stream).await +} + +/// Transform a portable exec request into the concrete argv/env that should be +/// spawned under the requested sandbox policy. +pub fn build_exec_request( + params: ExecParams, + sandbox_policy: &SandboxPolicy, + sandbox_cwd: &Path, + codex_linux_sandbox_exe: &Option, + use_linux_sandbox_bwrap: bool, +) -> Result { let windows_sandbox_level = params.windows_sandbox_level; let enforce_managed_network = params.network.is_some(); let sandbox_type = match &sandbox_policy { @@ -226,9 +257,7 @@ pub async fn process_exec_tool_call( windows_sandbox_level, }) .map_err(CodexErr::from)?; - - // Route through the sandboxing module for a single, unified execution path. - crate::sandboxing::execute_env(exec_req, stdout_stream).await + Ok(exec_req) } pub(crate) async fn execute_exec_request( @@ -796,16 +825,6 @@ async fn consume_truncated_output( } }; - // Wait for the stdout/stderr collection tasks but guard against them - // hanging forever. In the normal case, both pipes are closed once the child - // terminates so the tasks exit quickly. However, if the child process - // spawned grandchildren that inherited its stdout/stderr file descriptors - // those pipes may stay open after we `kill` the direct child on timeout. - // That would cause the `read_capped` tasks to block on `read()` - // indefinitely, effectively hanging the whole agent. - - const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; // 2 s should be plenty for local pipes - // We need mutable bindings so we can `abort()` them on timeout. use tokio::task::JoinHandle; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d96654bf4c..6fe1f9759a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -51,6 +51,7 @@ pub mod network_proxy_loader; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; +pub use text_encoding::bytes_to_string_smart; mod mcp_tool_call; mod memories; mod mentions; From 90469d0a230a4cbc05fa8e457a4341c322c75feb Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 6 Mar 2026 17:33:46 -0800 Subject: [PATCH 10/13] feat(app-server-protocol): address naming conflicts in json schema exporter (#13819) This fixes a schema export bug where two different `WebSearchAction` types were getting merged under the same name in the app-server v2 JSON schema bundle. The problem was that v2 thread items use the app-server API's `WebSearchAction` with camelCase variants like `openPage`, while `ThreadResumeParams.history` and `RawResponseItemCompletedNotification.item` pull in the upstream `ResponseItem` graph, which uses the Responses API snake_case shape like `open_page`. During bundle generation we were flattening nested definitions into the v2 namespace by plain name, so the later definition could silently overwrite the earlier one. That meant clients generating code from the bundled schema could end up with the wrong `WebSearchAction` definition for v2 thread history. In practice this shows up on web search items reconstructed from rollout files with persisted extended history. This change does two things: - Gives the upstream Responses API schema a distinct JSON schema name: `ResponsesApiWebSearchAction` - Makes namespace-level schema definition collisions fail loudly instead of silently overwriting --- .../schema/json/ClientRequest.json | 204 ++++++++--------- .../schema/json/EventMsg.json | 210 +++++++++--------- .../codex_app_server_protocol.schemas.json | 111 ++++++++- .../codex_app_server_protocol.v2.schemas.json | 111 ++++++++- .../RawResponseItemCompletedNotification.json | 20 +- .../schema/json/v2/ThreadResumeParams.json | 50 ++--- codex-rs/app-server-protocol/src/export.rs | 73 +++++- codex-rs/protocol/src/models.rs | 1 + 8 files changed, 526 insertions(+), 254 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index cbcf8b886f..c181b8bcf2 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1558,7 +1558,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, { "type": "null" @@ -1684,6 +1684,107 @@ } ] }, + "ResponsesApiWebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "find_in_page" + ], + "title": "FindInPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponsesApiWebSearchAction", + "type": "object" + } + ] + }, "ReviewDelivery": { "enum": [ "inline", @@ -2930,107 +3031,6 @@ } ] }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] - }, "WindowsSandboxSetupMode": { "enum": [ "elevated", diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index e17940056b..70ddde458e 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -1414,7 +1414,7 @@ { "properties": { "action": { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, "call_id": { "type": "string" @@ -5072,7 +5072,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, { "type": "null" @@ -5198,6 +5198,107 @@ } ] }, + "ResponsesApiWebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "find_in_page" + ], + "title": "FindInPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponsesApiWebSearchAction", + "type": "object" + } + ] + }, "Result_of_CallToolResult_or_String": { "oneOf": [ { @@ -6095,7 +6196,7 @@ { "properties": { "action": { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, "id": { "type": "string" @@ -6306,107 +6407,6 @@ "type": "object" } ] - }, - "WebSearchAction": { - "oneOf": [ - { - "properties": { - "queries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "query": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "search" - ], - "title": "SearchWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "SearchWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "open_page" - ], - "title": "OpenPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "OpenPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "find_in_page" - ], - "title": "FindInPageWebSearchActionType", - "type": "string" - }, - "url": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "type" - ], - "title": "FindInPageWebSearchAction", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "other" - ], - "title": "OtherWebSearchActionType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "OtherWebSearchAction", - "type": "object" - } - ] } }, "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", @@ -7226,7 +7226,7 @@ { "properties": { "action": { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, "call_id": { "type": "string" 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 3cc179456c..495339982b 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 @@ -2730,7 +2730,7 @@ { "properties": { "action": { - "$ref": "#/definitions/v2/WebSearchAction" + "$ref": "#/definitions/v2/ResponsesApiWebSearchAction" }, "call_id": { "type": "string" @@ -8236,7 +8236,7 @@ { "properties": { "action": { - "$ref": "#/definitions/v2/WebSearchAction" + "$ref": "#/definitions/v2/ResponsesApiWebSearchAction" }, "id": { "type": "string" @@ -13191,7 +13191,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/v2/WebSearchAction" + "$ref": "#/definitions/v2/ResponsesApiWebSearchAction" }, { "type": "null" @@ -13317,6 +13317,107 @@ } ] }, + "ResponsesApiWebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "find_in_page" + ], + "title": "FindInPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponsesApiWebSearchAction", + "type": "object" + } + ] + }, "ReviewDelivery": { "enum": [ "inline", @@ -16722,7 +16823,7 @@ "properties": { "type": { "enum": [ - "open_page" + "openPage" ], "title": "OpenPageWebSearchActionType", "type": "string" @@ -16750,7 +16851,7 @@ }, "type": { "enum": [ - "find_in_page" + "findInPage" ], "title": "FindInPageWebSearchActionType", "type": "string" 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 76f1d19d93..cbc28994bc 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 @@ -4543,7 +4543,7 @@ { "properties": { "action": { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, "call_id": { "type": "string" @@ -10052,7 +10052,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, { "type": "null" @@ -10178,6 +10178,107 @@ } ] }, + "ResponsesApiWebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "find_in_page" + ], + "title": "FindInPageResponsesApiWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageResponsesApiWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponsesApiWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponsesApiWebSearchAction", + "type": "object" + } + ] + }, "Result_of_CallToolResult_or_String": { "oneOf": [ { @@ -14485,7 +14586,7 @@ { "properties": { "action": { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, "id": { "type": "string" @@ -14978,7 +15079,7 @@ "properties": { "type": { "enum": [ - "open_page" + "openPage" ], "title": "OpenPageWebSearchActionType", "type": "string" @@ -15006,7 +15107,7 @@ }, "type": { "enum": [ - "find_in_page" + "findInPage" ], "title": "FindInPageWebSearchActionType", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index cb14542607..94a6c8ba77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -607,7 +607,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, { "type": "null" @@ -733,7 +733,7 @@ } ] }, - "WebSearchAction": { + "ResponsesApiWebSearchAction": { "oneOf": [ { "properties": { @@ -756,14 +756,14 @@ "enum": [ "search" ], - "title": "SearchWebSearchActionType", + "title": "SearchResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "SearchWebSearchAction", + "title": "SearchResponsesApiWebSearchAction", "type": "object" }, { @@ -772,7 +772,7 @@ "enum": [ "open_page" ], - "title": "OpenPageWebSearchActionType", + "title": "OpenPageResponsesApiWebSearchActionType", "type": "string" }, "url": { @@ -785,7 +785,7 @@ "required": [ "type" ], - "title": "OpenPageWebSearchAction", + "title": "OpenPageResponsesApiWebSearchAction", "type": "object" }, { @@ -800,7 +800,7 @@ "enum": [ "find_in_page" ], - "title": "FindInPageWebSearchActionType", + "title": "FindInPageResponsesApiWebSearchActionType", "type": "string" }, "url": { @@ -813,7 +813,7 @@ "required": [ "type" ], - "title": "FindInPageWebSearchAction", + "title": "FindInPageResponsesApiWebSearchAction", "type": "object" }, { @@ -822,14 +822,14 @@ "enum": [ "other" ], - "title": "OtherWebSearchActionType", + "title": "OtherResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "OtherWebSearchAction", + "title": "OtherResponsesApiWebSearchAction", "type": "object" } ] diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 3dce8b5df1..75c8a39675 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -657,7 +657,7 @@ "action": { "anyOf": [ { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/ResponsesApiWebSearchAction" }, { "type": "null" @@ -783,22 +783,7 @@ } ] }, - "SandboxMode": { - "enum": [ - "read-only", - "workspace-write", - "danger-full-access" - ], - "type": "string" - }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, - "WebSearchAction": { + "ResponsesApiWebSearchAction": { "oneOf": [ { "properties": { @@ -821,14 +806,14 @@ "enum": [ "search" ], - "title": "SearchWebSearchActionType", + "title": "SearchResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "SearchWebSearchAction", + "title": "SearchResponsesApiWebSearchAction", "type": "object" }, { @@ -837,7 +822,7 @@ "enum": [ "open_page" ], - "title": "OpenPageWebSearchActionType", + "title": "OpenPageResponsesApiWebSearchActionType", "type": "string" }, "url": { @@ -850,7 +835,7 @@ "required": [ "type" ], - "title": "OpenPageWebSearchAction", + "title": "OpenPageResponsesApiWebSearchAction", "type": "object" }, { @@ -865,7 +850,7 @@ "enum": [ "find_in_page" ], - "title": "FindInPageWebSearchActionType", + "title": "FindInPageResponsesApiWebSearchActionType", "type": "string" }, "url": { @@ -878,7 +863,7 @@ "required": [ "type" ], - "title": "FindInPageWebSearchAction", + "title": "FindInPageResponsesApiWebSearchAction", "type": "object" }, { @@ -887,17 +872,32 @@ "enum": [ "other" ], - "title": "OtherWebSearchActionType", + "title": "OtherResponsesApiWebSearchActionType", "type": "string" } }, "required": [ "type" ], - "title": "OtherWebSearchAction", + "title": "OtherResponsesApiWebSearchAction", "type": "object" } ] + }, + "SandboxMode": { + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ], + "type": "string" + }, + "ServiceTier": { + "enum": [ + "fast", + "flex" + ], + "type": "string" } }, "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index aad5ab7233..ba151046a1 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -1154,13 +1154,40 @@ fn insert_into_namespace( .or_insert_with(|| Value::Object(Map::new())); match entry { Value::Object(map) => { - map.insert(name, schema); - Ok(()) + insert_definition(map, name, schema, &format!("namespace `{namespace}`")) } _ => Err(anyhow!("expected namespace {namespace} to be an object")), } } +fn insert_definition( + definitions: &mut Map, + name: String, + schema: Value, + location: &str, +) -> Result<()> { + if let Some(existing) = definitions.get(&name) { + if existing == &schema { + return Ok(()); + } + + let existing_title = existing + .get("title") + .and_then(Value::as_str) + .unwrap_or(""); + let new_title = schema + .get("title") + .and_then(Value::as_str) + .unwrap_or(""); + return Err(anyhow!( + "schema definition collision in {location}: {name} (existing title: {existing_title}, new title: {new_title}); use #[schemars(rename = \"...\")] to rename one of the conflicting schema definitions" + )); + } + + definitions.insert(name, schema); + Ok(()) +} + fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result where T: JsonSchema, @@ -2291,6 +2318,48 @@ mod tests { Ok(()) } + #[test] + fn build_schema_bundle_rejects_conflicting_duplicate_definitions() { + let err = build_schema_bundle(vec![ + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "First".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "First", + "type": "object", + "definitions": { + "Shared": { + "title": "SharedString", + "type": "string" + } + } + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "Second".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "Second", + "type": "object", + "definitions": { + "Shared": { + "title": "SharedInteger", + "type": "integer" + } + } + }), + }, + ]) + .expect_err("conflicting schema definitions should be rejected"); + + assert_eq!( + err.to_string(), + "schema definition collision in namespace `v2`: Shared (existing title: SharedString, new title: SharedInteger); use #[schemars(rename = \"...\")] to rename one of the conflicting schema definitions" + ); + } + #[test] fn build_flat_v2_schema_keeps_shared_root_schemas_and_dependencies() -> Result<()> { let bundle = serde_json::json!({ diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index f211e4eff0..a4a2aa2cb3 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -879,6 +879,7 @@ pub struct LocalShellExecAction { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] +#[schemars(rename = "ResponsesApiWebSearchAction")] pub enum WebSearchAction { Search { #[serde(default, skip_serializing_if = "Option::is_none")] From 5deaf9409b3c4d39115d88dbe1aa6c2f9c22868e Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Mar 2026 17:46:23 -0800 Subject: [PATCH 11/13] fix: avoid invoking git before project trust is established (#13804) ## Summary - resolve trust roots by inspecting `.git` entries on disk instead of spawning `git rev-parse --git-common-dir` - keep regular repo and linked-worktree trust inheritance behavior intact - add a synthetic regression test that proves worktree trust resolution works without a real git command ## Testing - `just fmt` - `cargo test -p codex-core resolve_root_git_project_for_trust` - `cargo clippy -p codex-core --all-targets -- -D warnings` - `cargo test -p codex-core` (fails in this environment on unrelated managed-config `DangerFullAccess` tests in `codex::tests`, `tools::js_repl::tests`, and `unified_exec::tests`) --- codex-rs/core/src/git_info.rs | 106 ++++++++++++++++++++++++---------- 1 file changed, 74 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index d735d4dbda..676d230c20 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::collections::HashSet; +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; @@ -26,21 +27,12 @@ use tokio::time::timeout; /// directory. If you need Codex to work from such a checkout simply pass the /// `--allow-no-git-exec` CLI flag that disables the repo requirement. pub fn get_git_repo_root(base_dir: &Path) -> Option { - let mut dir = base_dir.to_path_buf(); - - loop { - if dir.join(".git").exists() { - return Some(dir); - } - - // Pop one component (go up one directory). `pop` returns false when - // we have reached the filesystem root. - if !dir.pop() { - break; - } - } - - None + let base = if base_dir.is_dir() { + base_dir + } else { + base_dir.parent()? + }; + find_ancestor_git_entry(base).map(|(repo_root, _)| repo_root) } /// Timeout for git commands to prevent freezing on large repositories @@ -609,30 +601,53 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option { /// Resolve the path that should be used for trust checks. Similar to /// `[get_git_repo_root]`, but resolves to the root of the main -/// repository. Handles worktrees. +/// repository. Handles worktrees via filesystem inspection without invoking +/// the `git` executable. pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option { let base = if cwd.is_dir() { cwd } else { cwd.parent()? }; + let (repo_root, dot_git) = find_ancestor_git_entry(base)?; + if dot_git.is_dir() { + return Some(canonicalize_or_raw(repo_root)); + } - // TODO: we should make this async, but it's primarily used deep in - // callstacks of sync code, and should almost always be fast - let git_dir_out = std::process::Command::new("git") - .args(["rev-parse", "--git-common-dir"]) - .current_dir(base) - .output() - .ok()?; - if !git_dir_out.status.success() { + let git_dir_s = std::fs::read_to_string(&dot_git).ok()?; + let git_dir_rel = git_dir_s.trim().strip_prefix("gitdir:")?.trim(); + if git_dir_rel.is_empty() { return None; } - let git_dir_s = String::from_utf8(git_dir_out.stdout) - .ok()? - .trim() - .to_string(); - let git_dir_path_raw = resolve_path(base, &PathBuf::from(&git_dir_s)); + let git_dir_path = canonicalize_or_raw(resolve_path(&repo_root, &PathBuf::from(git_dir_rel))); + let worktrees_dir = git_dir_path.parent()?; + if worktrees_dir.file_name() != Some(OsStr::new("worktrees")) { + return None; + } - // Normalize to handle macOS /var vs /private/var and resolve ".." segments. - let git_dir_path = std::fs::canonicalize(&git_dir_path_raw).unwrap_or(git_dir_path_raw); - git_dir_path.parent().map(Path::to_path_buf) + let common_dir = worktrees_dir.parent()?; + let main_repo_root = common_dir.parent()?; + Some(canonicalize_or_raw(main_repo_root.to_path_buf())) +} + +fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> { + let mut dir = base_dir.to_path_buf(); + + loop { + let dot_git = dir.join(".git"); + if dot_git.exists() { + return Some((dir, dot_git)); + } + + // Pop one component (go up one directory). `pop` returns false when + // we have reached the filesystem root. + if !dir.pop() { + break; + } + } + + None +} + +fn canonicalize_or_raw(path: PathBuf) -> PathBuf { + std::fs::canonicalize(&path).unwrap_or(path) } /// Returns a list of local git branches. @@ -1150,6 +1165,33 @@ mod tests { assert_eq!(got_nested, expected); } + #[test] + fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() { + let tmp = TempDir::new().expect("tempdir"); + let repo_root = tmp.path().join("repo"); + let common_dir = repo_root.join(".git"); + let worktree_git_dir = common_dir.join("worktrees").join("feature-x"); + let worktree_root = tmp.path().join("wt"); + std::fs::create_dir_all(&worktree_git_dir).unwrap(); + std::fs::create_dir_all(&worktree_root).unwrap(); + std::fs::create_dir_all(worktree_root.join("nested")).unwrap(); + std::fs::write( + worktree_root.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + ) + .unwrap(); + + let expected = std::fs::canonicalize(&repo_root).unwrap(); + assert_eq!( + resolve_root_git_project_for_trust(&worktree_root), + Some(expected.clone()) + ); + assert_eq!( + resolve_root_git_project_for_trust(&worktree_root.join("nested")), + Some(expected) + ); + } + #[test] fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() { let tmp = TempDir::new().expect("tempdir"); From 25fa9741660dfc95fffb23b67e52af4f56e30187 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 6 Mar 2026 17:52:54 -0800 Subject: [PATCH 12/13] fix: support managed network allowlist controls (#12752) ## Summary - treat `requirements.toml` `allowed_domains` and `denied_domains` as managed network baselines for the proxy - in restricted modes by default, build the effective runtime policy from the managed baseline plus user-configured allowlist and denylist entries, so common hosts can be pre-approved without blocking later user expansion - add `experimental_network.managed_allowed_domains_only = true` to pin the effective allowlist to managed entries, ignore user allowlist additions, and hard-deny non-managed domains without prompting - apply `managed_allowed_domains_only` anywhere managed network enforcement is active, including full access, while continuing to respect denied domains from all sources - add regression coverage for merged-baseline behavior, managed-only behavior, and full-access managed-only enforcement ## Behavior Assuming `requirements.toml` defines both `experimental_network.allowed_domains` and `experimental_network.denied_domains`. ### Default mode - By default, the effective allowlist is `experimental_network.allowed_domains` plus user or persisted allowlist additions. - By default, the effective denylist is `experimental_network.denied_domains` plus user or persisted denylist additions. - Allowlist misses can go through the network approval flow. - Explicit denylist hits and local or private-network blocks are still hard-denied. - When `experimental_network.managed_allowed_domains_only = true`, only managed `allowed_domains` are respected, user allowlist additions are ignored, and non-managed domains are hard-denied without prompting. - Denied domains continue to be respected from all sources. ### Full access - With managed requirements present, the effective allowlist is pinned to `experimental_network.allowed_domains`. - With managed requirements present, the effective denylist is pinned to `experimental_network.denied_domains`. - There is no allowlist-miss approval path in full access. - Explicit denylist hits are hard-denied. - `experimental_network.managed_allowed_domains_only = true` now also applies in full access, so managed-only behavior remains in effect anywhere managed network enforcement is active. --- codex-rs/app-server/src/config_api.rs | 1 + codex-rs/config/src/config_requirements.rs | 13 + codex-rs/core/src/config/mod.rs | 1 + .../core/src/config/network_proxy_spec.rs | 257 +++++++++++++++++- codex-rs/network-proxy/README.md | 2 +- codex-rs/network-proxy/src/config.rs | 4 +- codex-rs/network-proxy/src/runtime.rs | 130 +++++++++ codex-rs/network-proxy/src/state.rs | 152 ++++++++--- codex-rs/tui/src/debug_config.rs | 6 + 9 files changed, 520 insertions(+), 46 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 2f2f5a51f5..cf4b7da626 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -231,6 +231,7 @@ mod tests { dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), + managed_allowed_domains_only: Some(false), denied_domains: Some(vec!["example.com".to_string()]), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), allow_local_binding: Some(true), diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 9f4719ff16..7e6a976574 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -138,6 +138,9 @@ pub struct NetworkRequirementsToml { pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, pub allowed_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 allow_local_binding: Option, @@ -153,6 +156,9 @@ pub struct NetworkConstraints { pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, pub allowed_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 allow_local_binding: Option, @@ -168,6 +174,7 @@ impl From for NetworkConstraints { dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, allowed_domains, + managed_allowed_domains_only, denied_domains, allow_unix_sockets, allow_local_binding, @@ -180,6 +187,7 @@ impl From for NetworkConstraints { dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, allowed_domains, + managed_allowed_domains_only, denied_domains, allow_unix_sockets, allow_local_binding, @@ -1118,6 +1126,7 @@ mod tests { allow_upstream_proxy = false dangerously_allow_all_unix_sockets = true allowed_domains = ["api.example.com", "*.openai.com"] + managed_allowed_domains_only = true denied_domains = ["blocked.example.com"] allow_unix_sockets = ["/tmp/example.sock"] allow_local_binding = false @@ -1146,6 +1155,10 @@ mod tests { "*.openai.com".to_string() ]) ); + 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()]) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9f636d2684..f7ef78b26d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2308,6 +2308,7 @@ impl Config { let network = NetworkProxySpec::from_config_and_constraints( configured_network_proxy_config, network_requirements, + constrained_sandbox_policy.get(), ) .map_err(|err| { if let Some(source) = network_requirements_source.as_ref() { diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 671593dca4..9c70cf084c 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -21,6 +21,7 @@ use std::sync::Arc; pub struct NetworkProxySpec { config: NetworkProxyConfig, constraints: NetworkProxyConstraints, + hard_deny_allowlist_misses: bool, } pub struct StartedNetworkProxy { @@ -83,9 +84,18 @@ impl NetworkProxySpec { pub(crate) fn from_config_and_constraints( config: NetworkProxyConfig, requirements: Option, + sandbox_policy: &SandboxPolicy, ) -> std::io::Result { + let hard_deny_allowlist_misses = requirements + .as_ref() + .is_some_and(Self::managed_allowed_domains_only); let (config, constraints) = if let Some(requirements) = requirements { - Self::apply_requirements(config, &requirements) + Self::apply_requirements( + config, + &requirements, + sandbox_policy, + hard_deny_allowlist_misses, + ) } else { (config, NetworkProxyConstraints::default()) }; @@ -98,6 +108,7 @@ impl NetworkProxySpec { Ok(Self { config, constraints, + hard_deny_allowlist_misses, }) } @@ -112,6 +123,7 @@ impl NetworkProxySpec { let state = self.build_state_with_audit_metadata(audit_metadata)?; let mut builder = NetworkProxy::builder().state(Arc::new(state)); if enable_network_approval_flow + && !self.hard_deny_allowlist_misses && matches!( sandbox_policy, SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } @@ -158,8 +170,13 @@ impl NetworkProxySpec { fn apply_requirements( mut config: NetworkProxyConfig, requirements: &NetworkConstraints, + sandbox_policy: &SandboxPolicy, + hard_deny_allowlist_misses: bool, ) -> (NetworkProxyConfig, NetworkProxyConstraints) { let mut constraints = NetworkProxyConstraints::default(); + let allowlist_expansion_enabled = + Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses); + let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy); if let Some(enabled) = requirements.enabled { config.network.enabled = enabled; @@ -190,13 +207,31 @@ impl NetworkProxySpec { constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); } - if let Some(allowed_domains) = requirements.allowed_domains.clone() { - config.network.allowed_domains = allowed_domains.clone(); + let managed_allowed_domains = if hard_deny_allowlist_misses { + Some(requirements.allowed_domains.clone().unwrap_or_default()) + } else { + requirements.allowed_domains.clone() + }; + if let Some(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) + } else { + allowed_domains.clone() + }; constraints.allowed_domains = Some(allowed_domains); + constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled); } if let Some(denied_domains) = requirements.denied_domains.clone() { - config.network.denied_domains = denied_domains.clone(); + config.network.denied_domains = if denylist_expansion_enabled { + Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains) + } else { + denied_domains.clone() + }; constraints.denied_domains = Some(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(); @@ -209,6 +244,39 @@ impl NetworkProxySpec { (config, constraints) } + + fn allowlist_expansion_enabled( + sandbox_policy: &SandboxPolicy, + hard_deny_allowlist_misses: bool, + ) -> bool { + matches!( + sandbox_policy, + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } + ) && !hard_deny_allowlist_misses + } + + fn managed_allowed_domains_only(requirements: &NetworkConstraints) -> bool { + requirements.managed_allowed_domains_only.unwrap_or(false) + } + + fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool { + matches!( + sandbox_policy, + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } + ) + } + + fn merge_domain_lists(mut managed: Vec, user_entries: &[String]) -> Vec { + for entry in user_entries { + if !managed + .iter() + .any(|managed_entry| managed_entry.eq_ignore_ascii_case(entry)) + { + managed.push(entry.clone()); + } + } + managed + } } #[cfg(test)] @@ -221,6 +289,7 @@ mod tests { let spec = NetworkProxySpec { config: NetworkProxyConfig::default(), constraints: NetworkProxyConstraints::default(), + hard_deny_allowlist_misses: false, }; let metadata = NetworkProxyAuditMetadata { conversation_id: Some("conversation-1".to_string()), @@ -234,4 +303,184 @@ mod tests { .expect("state should build"); assert_eq!(state.audit_metadata(), &metadata); } + + #[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()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_read_only_policy(), + ) + .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()] + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["*.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); + } + + #[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()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("yolo mode should pin the effective policy to the managed baseline"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["*.example.com".to_string()] + ); + assert_eq!( + spec.config.network.denied_domains, + vec!["blocked.example.com".to_string()] + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); + } + + #[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()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still load"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["*.example.com".to_string()] + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + } + + #[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()]; + let requirements = NetworkConstraints { + allowed_domains: Some(vec!["managed.example.com".to_string()]), + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed-only allowlist should still load"); + + assert_eq!( + spec.config.network.allowed_domains, + vec!["managed.example.com".to_string()] + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["managed.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); + assert!(spec.hard_deny_allowlist_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()]; + let requirements = NetworkConstraints { + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed-only mode should treat missing managed allowlist as empty"); + + assert!(spec.config.network.allowed_domains.is_empty()); + 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 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()]; + let requirements = NetworkConstraints { + managed_allowed_domains_only: Some(true), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("managed-only mode should treat missing managed allowlist as empty"); + + assert!(spec.config.network.allowed_domains.is_empty()); + 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 requirements_denied_domains_are_a_baseline_for_default_mode() { + let mut config = NetworkProxyConfig::default(); + config.network.denied_domains = vec!["blocked.example.com".to_string()]; + let requirements = NetworkConstraints { + denied_domains: Some(vec!["managed-blocked.example.com".to_string()]), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("default mode should merge managed and user deny entries"); + + assert_eq!( + spec.config.network.denied_domains, + vec![ + "managed-blocked.example.com".to_string(), + "blocked.example.com".to_string() + ] + ); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); + } } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 6212b94264..6fb689490a 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -47,7 +47,7 @@ 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 = true +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"] diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 817dda26d0..e00ec1944a 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -60,7 +60,7 @@ impl Default for NetworkProxySettings { allowed_domains: Vec::new(), denied_domains: Vec::new(), allow_unix_sockets: Vec::new(), - allow_local_binding: true, + allow_local_binding: false, mitm: false, } } @@ -374,7 +374,7 @@ mod tests { allowed_domains: Vec::new(), denied_domains: Vec::new(), allow_unix_sockets: Vec::new(), - allow_local_binding: true, + allow_local_binding: false, mitm: false, } ); diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b825495809..d043106fa7 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -894,6 +894,98 @@ 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() + }, + }; + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["managed.example.com".to_string()]), + allowlist_expansion_enabled: Some(true), + ..NetworkProxyConstraints::default() + }; + let state = NetworkProxyState::with_reloader( + build_config_state(config, constraints).unwrap(), + Arc::new(NoopReloader), + ); + + state.add_allowed_domain("user.example.com").await.unwrap(); + + let (allowed, denied) = state.current_patterns().await.unwrap(); + assert_eq!( + allowed, + vec![ + "managed.example.com".to_string(), + "user.example.com".to_string() + ] + ); + assert!(denied.is_empty()); + } + + #[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() + }, + }; + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["managed.example.com".to_string()]), + allowlist_expansion_enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + let state = NetworkProxyState::with_reloader( + build_config_state(config, constraints).unwrap(), + Arc::new(NoopReloader), + ); + + let err = state + .add_allowed_domain("user.example.com") + .await + .expect_err("managed baseline should reject allowlist expansion"); + + assert!( + format!("{err:#}").contains("network.allowed_domains constrained by managed config"), + "unexpected error: {err:#}" + ); + } + + #[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() + }, + }; + let constraints = NetworkProxyConstraints { + denied_domains: Some(vec!["managed.example.com".to_string()]), + denylist_expansion_enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + let state = NetworkProxyState::with_reloader( + build_config_state(config, constraints).unwrap(), + Arc::new(NoopReloader), + ); + + let err = state + .add_denied_domain("user.example.com") + .await + .expect_err("managed baseline should reject denylist expansion"); + + assert!( + format!("{err:#}").contains("network.denied_domains constrained by managed config"), + "unexpected error: {err:#}" + ); + } + #[tokio::test] async fn blocked_snapshot_does_not_consume_entries() { let state = network_proxy_state_for_policy(NetworkProxySettings::default()); @@ -1117,6 +1209,25 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } + #[test] + fn validate_policy_against_constraints_allows_expanding_allowed_domains_when_enabled() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["example.com".to_string()]), + allowlist_expansion_enabled: Some(true), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + allowed_domains: vec!["example.com".to_string(), "api.openai.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + #[test] fn validate_policy_against_constraints_disallows_widening_mode() { let constraints = NetworkProxyConstraints { @@ -1245,6 +1356,25 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_err()); } + #[test] + fn validate_policy_against_constraints_disallows_expanding_denied_domains_when_fixed() { + let constraints = NetworkProxyConstraints { + denied_domains: Some(vec!["evil.com".to_string()]), + denylist_expansion_enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + denied_domains: vec!["evil.com".to_string(), "more-evil.com".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + #[test] fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() { let constraints = NetworkProxyConstraints { diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index fb8898f614..612e6c5b5c 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -24,7 +24,9 @@ pub struct NetworkProxyConstraints { pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, + pub allowlist_expansion_enabled: Option, pub denied_domains: Option>, + pub denylist_expansion_enabled: Option, pub allow_unix_sockets: Option>, pub allow_local_binding: Option, } @@ -207,31 +209,82 @@ pub fn validate_policy_against_constraints( if let Some(allowed_domains) = &constraints.allowed_domains { validate_domain_patterns("network.allowed_domains", allowed_domains)?; - let managed_patterns: Vec = allowed_domains - .iter() - .map(|entry| DomainPattern::parse_for_constraints(entry)) - .collect(); - validate(config.network.allowed_domains.clone(), move |candidate| { - let mut invalid = Vec::new(); - for entry in candidate { - let candidate_pattern = DomainPattern::parse_for_constraints(entry); - if !managed_patterns + match constraints.allowlist_expansion_enabled { + Some(true) => { + let required_set: HashSet = allowed_domains .iter() - .any(|managed| managed.allows(&candidate_pattern)) - { - invalid.push(entry.clone()); - } + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + validate(config.network.allowed_domains.clone(), move |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)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network.allowed_domains", + "missing managed allowed_domains entries", + format!("{missing:?}"), + )) + } + })?; } - if invalid.is_empty() { - Ok(()) - } else { - Err(invalid_value( - "network.allowed_domains", - format!("{invalid:?}"), - "subset of managed allowed_domains", - )) + Some(false) => { + let required_set: HashSet = allowed_domains + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + validate(config.network.allowed_domains.clone(), move |candidate| { + let candidate_set: HashSet = candidate + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + if candidate_set == required_set { + Ok(()) + } else { + Err(invalid_value( + "network.allowed_domains", + format!("{candidate:?}"), + "must match managed allowed_domains", + )) + } + })?; } - })?; + None => { + let managed_patterns: Vec = allowed_domains + .iter() + .map(|entry| DomainPattern::parse_for_constraints(entry)) + .collect(); + validate(config.network.allowed_domains.clone(), move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + let candidate_pattern = DomainPattern::parse_for_constraints(entry); + if !managed_patterns + .iter() + .any(|managed| managed.allows(&candidate_pattern)) + { + invalid.push(entry.clone()); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network.allowed_domains", + format!("{invalid:?}"), + "subset of managed allowed_domains", + )) + } + })?; + } + } } if let Some(denied_domains) = &constraints.denied_domains { @@ -240,24 +293,45 @@ pub fn validate_policy_against_constraints( .iter() .map(|s| s.to_ascii_lowercase()) .collect(); - validate(config.network.denied_domains.clone(), move |candidate| { - let candidate_set: HashSet = - candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); - let missing: Vec = required_set - .iter() - .filter(|entry| !candidate_set.contains(*entry)) - .cloned() - .collect(); - if missing.is_empty() { - Ok(()) - } else { - Err(invalid_value( - "network.denied_domains", - "missing managed denied_domains entries", - format!("{missing:?}"), - )) + match constraints.denylist_expansion_enabled { + Some(false) => { + validate(config.network.denied_domains.clone(), move |candidate| { + let candidate_set: HashSet = candidate + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + if candidate_set == required_set { + Ok(()) + } else { + Err(invalid_value( + "network.denied_domains", + format!("{candidate:?}"), + "must match managed denied_domains", + )) + } + })?; } - })?; + Some(true) | None => { + validate(config.network.denied_domains.clone(), move |candidate| { + let candidate_set: HashSet = + candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); + let missing: Vec = required_set + .iter() + .filter(|entry| !candidate_set.contains(*entry)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network.denied_domains", + "missing managed denied_domains entries", + format!("{missing:?}"), + )) + } + })?; + } + } } if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets { diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 7cab734370..d4b19a97b1 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -331,6 +331,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, allowed_domains, + managed_allowed_domains_only, denied_domains, allow_unix_sockets, allow_local_binding, @@ -361,6 +362,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { if let Some(allowed_domains) = allowed_domains { parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); } + 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(", "))); } From 22ac6b9aaaa2b796f766845f32681ccfbb33a3b4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 6 Mar 2026 18:30:21 -0800 Subject: [PATCH 13/13] sandboxing: plumb split sandbox policies through runtime (#13439) ## Why `#13434` introduces split `FileSystemSandboxPolicy` and `NetworkSandboxPolicy`, but the runtime still made most execution-time sandbox decisions from the legacy `SandboxPolicy` projection. That projection loses information about combinations like unrestricted filesystem access with restricted network access. In practice, that means the runtime can choose the wrong platform sandbox behavior or set the wrong network-restriction environment for a command even when config has already separated those concerns. This PR carries the split policies through the runtime so sandbox selection, process spawning, and exec handling can consult the policy that actually matters. ## What changed - threaded `FileSystemSandboxPolicy` and `NetworkSandboxPolicy` through `TurnContext`, `ExecRequest`, sandbox attempts, shell escalation state, unified exec, and app-server exec overrides - updated sandbox selection in `core/src/sandboxing/mod.rs` and `core/src/exec.rs` to key off `FileSystemSandboxPolicy.kind` plus `NetworkSandboxPolicy`, rather than inferring behavior only from the legacy `SandboxPolicy` - updated process spawning in `core/src/spawn.rs` and the platform wrappers to use `NetworkSandboxPolicy` when deciding whether to set `CODEX_SANDBOX_NETWORK_DISABLED` - kept additional-permissions handling and legacy `ExternalSandbox` compatibility projections aligned with the split policies, including explicit user-shell execution and Windows restricted-token routing - updated callers across `core`, `app-server`, and `linux-sandbox` to pass the split policies explicitly ## Verification - added regression coverage in `core/tests/suite/user_shell_cmd.rs` to verify `RunUserShellCommand` does not inherit `CODEX_SANDBOX_NETWORK_DISABLED` from the active turn - added coverage in `core/src/exec.rs` for Windows restricted-token sandbox selection when the legacy projection is `ExternalSandbox` - updated Linux sandbox coverage in `linux-sandbox/tests/suite/landlock.rs` to exercise the split-policy exec path - verified the current PR state with `just clippy` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13439). * #13453 * #13452 * #13451 * #13449 * #13448 * #13445 * #13440 * __->__ #13439 --------- Co-authored-by: viyatb-oai --- .../app-server/src/codex_message_processor.rs | 22 +- codex-rs/app-server/src/command_exec.rs | 24 +- codex-rs/core/src/codex.rs | 18 + codex-rs/core/src/codex_tests.rs | 20 +- codex-rs/core/src/exec.rs | 229 +++++++- codex-rs/core/src/landlock.rs | 3 +- codex-rs/core/src/sandboxing/mod.rs | 500 ++++++++++++++++-- codex-rs/core/src/seatbelt.rs | 3 +- codex-rs/core/src/spawn.rs | 16 +- codex-rs/core/src/tasks/user_shell.rs | 4 + codex-rs/core/src/tools/js_repl/mod.rs | 5 +- codex-rs/core/src/tools/orchestrator.rs | 7 +- .../tools/runtimes/shell/unix_escalation.rs | 46 +- .../runtimes/shell/unix_escalation_tests.rs | 41 +- codex-rs/core/src/tools/sandboxing.rs | 6 + codex-rs/core/src/unified_exec/mod.rs | 4 + codex-rs/core/tests/suite/exec.rs | 14 +- codex-rs/core/tests/suite/user_shell_cmd.rs | 31 ++ .../linux-sandbox/tests/suite/landlock.rs | 6 + codex-rs/protocol/src/approvals.rs | 4 + 20 files changed, 913 insertions(+), 90 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d0e10a84ea..e069f9abb3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1657,9 +1657,19 @@ impl CodexMessageProcessor { }; let requested_policy = sandbox_policy.map(|policy| policy.to_core()); - let effective_policy = match requested_policy { + let ( + effective_policy, + effective_file_system_sandbox_policy, + effective_network_sandbox_policy, + ) = match requested_policy { Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { - Ok(()) => policy, + Ok(()) => { + let file_system_sandbox_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from(&policy); + let network_sandbox_policy = + codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); + (policy, file_system_sandbox_policy, network_sandbox_policy) + } Err(err) => { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -1670,7 +1680,11 @@ impl CodexMessageProcessor { return; } }, - None => self.config.permissions.sandbox_policy.get().clone(), + None => ( + self.config.permissions.sandbox_policy.get().clone(), + self.config.permissions.file_system_sandbox_policy.clone(), + self.config.permissions.network_sandbox_policy, + ), }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); @@ -1691,6 +1705,8 @@ impl CodexMessageProcessor { match codex_core::exec::build_exec_request( exec_params, &effective_policy, + &effective_file_system_sandbox_policy, + effective_network_sandbox_policy, sandbox_cwd.as_path(), &codex_linux_sandbox_exe, use_linux_sandbox_bwrap, diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 85448ea963..540298b041 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -702,6 +702,8 @@ mod tests { use std::path::PathBuf; use codex_protocol::config_types::WindowsSandboxLevel; + use codex_protocol::permissions::FileSystemSandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; @@ -719,6 +721,10 @@ mod tests { use crate::outgoing_message::OutgoingMessage; fn windows_sandbox_exec_request() -> ExecRequest { + let sandbox_policy = SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }; ExecRequest { command: vec!["cmd".to_string()], cwd: PathBuf::from("."), @@ -728,10 +734,9 @@ mod tests { sandbox: SandboxType::WindowsRestrictedToken, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, - sandbox_policy: SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }, + sandbox_policy: sandbox_policy.clone(), + file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), + network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), justification: None, arg0: None, } @@ -821,6 +826,10 @@ mod tests { connection_id: ConnectionId(8), request_id: codex_app_server_protocol::RequestId::Integer(100), }; + let sandbox_policy = SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }; manager .start(StartCommandExecParams { @@ -836,10 +845,9 @@ mod tests { sandbox: SandboxType::None, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, - sandbox_policy: SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }, + sandbox_policy: sandbox_policy.clone(), + file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), + network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), justification: None, arg0: None, }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4292552a31..87240d398c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -87,6 +87,8 @@ use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::format_allow_prefixes; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; use codex_protocol::protocol::ItemCompletedEvent; @@ -488,6 +490,8 @@ impl Codex { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), sandbox_policy: config.permissions.sandbox_policy.clone(), + file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: config.permissions.network_sandbox_policy, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -683,6 +687,8 @@ pub(crate) struct TurnContext { pub(crate) personality: Option, pub(crate) approval_policy: Constrained, pub(crate) sandbox_policy: Constrained, + pub(crate) file_system_sandbox_policy: FileSystemSandboxPolicy, + pub(crate) network_sandbox_policy: NetworkSandboxPolicy, pub(crate) network: Option, pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, @@ -774,6 +780,8 @@ impl TurnContext { personality: self.personality, approval_policy: self.approval_policy.clone(), sandbox_policy: self.sandbox_policy.clone(), + file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), + network_sandbox_policy: self.network_sandbox_policy, network: self.network.clone(), windows_sandbox_level: self.windows_sandbox_level, shell_environment_policy: self.shell_environment_policy.clone(), @@ -879,6 +887,8 @@ pub(crate) struct SessionConfiguration { approval_policy: Constrained, /// How to sandbox commands executed in the system sandbox_policy: Constrained, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, /// Working directory that should be treated as the *root* of the @@ -945,6 +955,10 @@ impl SessionConfiguration { } if let Some(sandbox_policy) = updates.sandbox_policy.clone() { next_configuration.sandbox_policy.set(sandbox_policy)?; + next_configuration.file_system_sandbox_policy = + FileSystemSandboxPolicy::from(next_configuration.sandbox_policy.get()); + next_configuration.network_sandbox_policy = + NetworkSandboxPolicy::from(next_configuration.sandbox_policy.get()); } if let Some(windows_sandbox_level) = updates.windows_sandbox_level { next_configuration.windows_sandbox_level = windows_sandbox_level; @@ -1158,6 +1172,8 @@ impl Session { personality: session_configuration.personality, approval_policy: session_configuration.approval_policy.clone(), sandbox_policy: session_configuration.sandbox_policy.clone(), + file_system_sandbox_policy: session_configuration.file_system_sandbox_policy.clone(), + network_sandbox_policy: session_configuration.network_sandbox_policy, network, windows_sandbox_level: session_configuration.windows_sandbox_level, shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), @@ -4986,6 +5002,8 @@ async fn spawn_review_thread( personality: parent_turn_context.personality, approval_policy: parent_turn_context.approval_policy.clone(), sandbox_policy: parent_turn_context.sandbox_policy.clone(), + file_system_sandbox_policy: parent_turn_context.file_system_sandbox_policy.clone(), + network_sandbox_policy: parent_turn_context.network_sandbox_policy, network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 7fe6076f72..b5f2ea2ef4 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1416,6 +1416,8 @@ async fn set_rate_limits_retains_previous_credits() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), sandbox_policy: config.permissions.sandbox_policy.clone(), + file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: config.permissions.network_sandbox_policy, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -1510,6 +1512,8 @@ async fn set_rate_limits_updates_plan_type_when_present() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), sandbox_policy: config.permissions.sandbox_policy.clone(), + file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: config.permissions.network_sandbox_policy, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -1862,6 +1866,8 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), sandbox_policy: config.permissions.sandbox_policy.clone(), + file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: config.permissions.network_sandbox_policy, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -1919,6 +1925,8 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), sandbox_policy: config.permissions.sandbox_policy.clone(), + file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: config.permissions.network_sandbox_policy, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -2009,6 +2017,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), sandbox_policy: config.permissions.sandbox_policy.clone(), + file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: config.permissions.network_sandbox_policy, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -2414,6 +2424,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), sandbox_policy: config.permissions.sandbox_policy.clone(), + file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: config.permissions.network_sandbox_policy, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3841,11 +3853,15 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { // Now retry the same command WITHOUT escalated permissions; should succeed. // Force DangerFullAccess to avoid platform sandbox dependencies in tests. - Arc::get_mut(&mut turn_context) - .expect("unique turn context Arc") + let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc"); + turn_context_mut .sandbox_policy .set(SandboxPolicy::DangerFullAccess) .expect("test setup should allow updating sandbox policy"); + turn_context_mut.file_system_sandbox_policy = + FileSystemSandboxPolicy::from(turn_context_mut.sandbox_policy.get()); + turn_context_mut.network_sandbox_policy = + NetworkSandboxPolicy::from(turn_context_mut.sandbox_policy.get()); let resp2 = handler .handle(ToolInvocation { diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index b88b498349..7ea18afc21 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -19,7 +19,6 @@ use tokio_util::sync::CancellationToken; use crate::error::CodexErr; use crate::error::Result; use crate::error::SandboxErr; -use crate::get_platform_sandbox; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecCommandOutputDeltaEvent; @@ -33,7 +32,11 @@ use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; +use crate::tools::sandboxing::SandboxablePreference; use codex_network_proxy::NetworkProxy; +use codex_protocol::permissions::FileSystemSandboxKind; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; @@ -82,6 +85,21 @@ pub struct ExecParams { pub arg0: Option, } +fn select_process_exec_tool_sandbox_type( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, + enforce_managed_network: bool, +) -> SandboxType { + SandboxManager::new().select_initial( + file_system_sandbox_policy, + network_sandbox_policy, + SandboxablePreference::Auto, + windows_sandbox_level, + enforce_managed_network, + ) +} + /// Mechanism to terminate an exec invocation before it finishes naturally. #[derive(Clone, Debug)] pub enum ExecExpiration { @@ -159,9 +177,12 @@ pub struct StdoutStream { pub tx_event: Sender, } +#[allow(clippy::too_many_arguments)] pub async fn process_exec_tool_call( params: ExecParams, sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, sandbox_cwd: &Path, codex_linux_sandbox_exe: &Option, use_linux_sandbox_bwrap: bool, @@ -170,6 +191,8 @@ pub async fn process_exec_tool_call( let exec_req = build_exec_request( params, sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, sandbox_cwd, codex_linux_sandbox_exe, use_linux_sandbox_bwrap, @@ -184,29 +207,20 @@ pub async fn process_exec_tool_call( pub fn build_exec_request( params: ExecParams, sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, sandbox_cwd: &Path, codex_linux_sandbox_exe: &Option, use_linux_sandbox_bwrap: bool, ) -> Result { let windows_sandbox_level = params.windows_sandbox_level; let enforce_managed_network = params.network.is_some(); - let sandbox_type = match &sandbox_policy { - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - if enforce_managed_network { - get_platform_sandbox( - windows_sandbox_level - != codex_protocol::config_types::WindowsSandboxLevel::Disabled, - ) - .unwrap_or(SandboxType::None) - } else { - SandboxType::None - } - } - _ => get_platform_sandbox( - windows_sandbox_level != codex_protocol::config_types::WindowsSandboxLevel::Disabled, - ) - .unwrap_or(SandboxType::None), - }; + let sandbox_type = select_process_exec_tool_sandbox_type( + file_system_sandbox_policy, + network_sandbox_policy, + windows_sandbox_level, + enforce_managed_network, + ); tracing::debug!("Sandbox type: {sandbox_type:?}"); let ExecParams { @@ -246,6 +260,8 @@ pub fn build_exec_request( .transform(crate::sandboxing::SandboxTransformRequest { spec, policy: sandbox_policy, + file_system_policy: file_system_sandbox_policy, + network_policy: network_sandbox_policy, sandbox: sandbox_type, enforce_managed_network, network: network.as_ref(), @@ -276,9 +292,12 @@ pub(crate) async fn execute_exec_request( windows_sandbox_level, sandbox_permissions, sandbox_policy: _sandbox_policy_from_env, + file_system_sandbox_policy, + network_sandbox_policy, justification, arg0, } = exec_request; + let _ = _sandbox_policy_from_env; let params = ExecParams { command, @@ -293,7 +312,16 @@ pub(crate) async fn execute_exec_request( }; let start = Instant::now(); - let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream, after_spawn).await; + let raw_output_result = exec( + params, + sandbox, + sandbox_policy, + &file_system_sandbox_policy, + network_sandbox_policy, + stdout_stream, + after_spawn, + ) + .await; let duration = start.elapsed(); finalize_exec_result(raw_output_result, sandbox, duration) } @@ -722,16 +750,21 @@ async fn exec( params: ExecParams, sandbox: SandboxType, sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, stdout_stream: Option, after_spawn: Option>, ) -> Result { #[cfg(target_os = "windows")] - if sandbox == SandboxType::WindowsRestrictedToken - && !matches!( + if sandbox == SandboxType::WindowsRestrictedToken { + if let Some(reason) = unsupported_windows_restricted_token_sandbox_reason( + sandbox, sandbox_policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) - { + file_system_sandbox_policy, + network_sandbox_policy, + ) { + return Err(CodexErr::Io(io::Error::other(reason))); + } return exec_windows_sandbox(params, sandbox_policy).await; } let ExecParams { @@ -760,7 +793,7 @@ async fn exec( args: args.into(), arg0: arg0_ref, cwd, - sandbox_policy, + network_sandbox_policy, // The environment already has attempt-scoped proxy settings from // apply_to_env_for_attempt above. Passing network here would reapply // non-attempt proxy vars and drop attempt correlation metadata. @@ -775,6 +808,43 @@ async fn exec( consume_truncated_output(child, expiration, stdout_stream).await } +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn should_use_windows_restricted_token_sandbox( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, +) -> bool { + sandbox == SandboxType::WindowsRestrictedToken + && file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted + && !matches!( + sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) +} + +#[cfg(any(target_os = "windows", test))] +fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, +) -> Option { + if should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) { + return None; + } + + (sandbox == SandboxType::WindowsRestrictedToken).then(|| { + format!( + "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed", + file_system_sandbox_policy.kind, + ) + }) +} + /// Consumes the output of a child process, truncating it so it is suitable for /// use as the output of a `shell` tool call. Also enforces specified timeout. async fn consume_truncated_output( @@ -1117,6 +1187,111 @@ mod tests { assert_eq!(aggregated.truncated_after_lines, None); } + #[test] + fn windows_restricted_token_skips_external_sandbox_policies() { + let policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + + assert_eq!( + should_use_windows_restricted_token_sandbox( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + ), + false + ); + } + + #[test] + fn windows_restricted_token_runs_for_legacy_restricted_policies() { + let policy = SandboxPolicy::new_read_only_policy(); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + + assert_eq!( + should_use_windows_restricted_token_sandbox( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + ), + true + ); + } + + #[test] + fn windows_restricted_token_rejects_network_only_restrictions() { + let policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let file_system_policy = FileSystemSandboxPolicy::unrestricted(); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + Some( + "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + ) + ); + } + + #[test] + fn windows_restricted_token_allows_legacy_restricted_policies() { + let policy = SandboxPolicy::new_read_only_policy(); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + None + ); + } + + #[test] + fn windows_restricted_token_allows_legacy_workspace_write_policies() { + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let file_system_policy = FileSystemSandboxPolicy::from(&policy); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + None + ); + } + + #[test] + fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() { + let expected = crate::get_platform_sandbox(false).unwrap_or(SandboxType::None); + + assert_eq!( + select_process_exec_tool_sandbox_type( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Restricted, + codex_protocol::config_types::WindowsSandboxLevel::Disabled, + false, + ), + expected + ); + } + #[cfg(unix)] #[test] fn sandbox_detection_flags_sigsys_exit_code() { @@ -1159,6 +1334,8 @@ mod tests { params, SandboxType::None, &SandboxPolicy::new_read_only_policy(), + &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), + NetworkSandboxPolicy::Restricted, None, None, ) @@ -1215,6 +1392,8 @@ mod tests { let result = process_exec_tool_call( params, &SandboxPolicy::DangerFullAccess, + &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), + NetworkSandboxPolicy::Enabled, cwd.as_path(), &None, false, diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 65b2a68073..fdb8a52af1 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -3,6 +3,7 @@ use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use codex_network_proxy::NetworkProxy; +use codex_protocol::permissions::NetworkSandboxPolicy; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -44,7 +45,7 @@ where args, arg0, cwd: command_cwd, - sandbox_policy, + network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy), network, stdio_policy, env, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 9258889c79..7c204f4362 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -30,6 +30,14 @@ use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::SandboxPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxKind; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::ReadOnlyAccess; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; @@ -62,6 +70,8 @@ pub struct ExecRequest { pub windows_sandbox_level: WindowsSandboxLevel, pub sandbox_permissions: SandboxPermissions, pub sandbox_policy: SandboxPolicy, + pub file_system_sandbox_policy: FileSystemSandboxPolicy, + pub network_sandbox_policy: NetworkSandboxPolicy, pub justification: Option, pub arg0: Option, } @@ -72,6 +82,8 @@ pub struct ExecRequest { pub(crate) struct SandboxTransformRequest<'a> { pub spec: CommandSpec, pub policy: &'a SandboxPolicy, + pub file_system_policy: &'a FileSystemSandboxPolicy, + pub network_policy: NetworkSandboxPolicy, pub sandbox: SandboxType, pub enforce_managed_network: bool, // TODO(viyatb): Evaluate switching this to Option> @@ -203,6 +215,41 @@ fn additional_permission_roots( ) } +#[cfg_attr(not(test), allow(dead_code))] +fn merge_file_system_policy_with_additional_permissions( + file_system_policy: &FileSystemSandboxPolicy, + extra_reads: Vec, + extra_writes: Vec, +) -> FileSystemSandboxPolicy { + match file_system_policy.kind { + FileSystemSandboxKind::Restricted => { + let mut merged_policy = file_system_policy.clone(); + for path in extra_reads { + let entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + }; + if !merged_policy.entries.contains(&entry) { + merged_policy.entries.push(entry); + } + } + for path in extra_writes { + let entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Write, + }; + if !merged_policy.entries.contains(&entry) { + merged_policy.entries.push(entry); + } + } + merged_policy + } + FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => { + file_system_policy.clone() + } + } +} + fn merge_read_only_access_with_additional_reads( read_only_access: &ReadOnlyAccess, extra_reads: Vec, @@ -246,9 +293,17 @@ fn sandbox_policy_with_additional_permissions( let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); match sandbox_policy { - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - sandbox_policy.clone() - } + SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess, + SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox { + network_access: if merge_network_access( + network_access.is_enabled(), + additional_permissions, + ) { + NetworkAccess::Enabled + } else { + NetworkAccess::Restricted + }, + }, SandboxPolicy::WorkspaceWrite { writable_roots, read_only_access, @@ -297,6 +352,35 @@ fn sandbox_policy_with_additional_permissions( } } +pub(crate) fn should_require_platform_sandbox( + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + has_managed_network_requirements: bool, +) -> bool { + if has_managed_network_requirements { + return true; + } + + if !network_policy.is_enabled() { + return !matches!( + file_system_policy.kind, + FileSystemSandboxKind::ExternalSandbox + ); + } + + match file_system_policy.kind { + FileSystemSandboxKind::Restricted => !file_system_policy.entries.iter().any(|entry| { + entry.access == FileSystemAccessMode::Write + && matches!( + &entry.path, + FileSystemPath::Special { value } + if matches!(value, FileSystemSpecialPath::Root) + ) + }), + FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false, + } +} + #[derive(Default)] pub struct SandboxManager; @@ -307,7 +391,8 @@ impl SandboxManager { pub(crate) fn select_initial( &self, - policy: &SandboxPolicy, + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, pref: SandboxablePreference, windows_sandbox_level: WindowsSandboxLevel, has_managed_network_requirements: bool, @@ -322,22 +407,20 @@ impl SandboxManager { ) .unwrap_or(SandboxType::None) } - SandboxablePreference::Auto => match policy { - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - if has_managed_network_requirements { - crate::safety::get_platform_sandbox( - windows_sandbox_level != WindowsSandboxLevel::Disabled, - ) - .unwrap_or(SandboxType::None) - } else { - SandboxType::None - } + SandboxablePreference::Auto => { + if should_require_platform_sandbox( + file_system_policy, + network_policy, + has_managed_network_requirements, + ) { + crate::safety::get_platform_sandbox( + windows_sandbox_level != WindowsSandboxLevel::Disabled, + ) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None } - _ => crate::safety::get_platform_sandbox( - windows_sandbox_level != WindowsSandboxLevel::Disabled, - ) - .unwrap_or(SandboxType::None), - }, + } } } @@ -348,6 +431,8 @@ impl SandboxManager { let SandboxTransformRequest { mut spec, policy, + file_system_policy, + network_policy, sandbox, enforce_managed_network, network, @@ -360,16 +445,41 @@ impl SandboxManager { } = request; #[cfg(not(target_os = "macos"))] let macos_seatbelt_profile_extensions = None; - let effective_permissions = EffectiveSandboxPermissions::new( + let additional_permissions = spec.additional_permissions.take(); + let EffectiveSandboxPermissions { + sandbox_policy: effective_policy, + macos_seatbelt_profile_extensions: _effective_macos_seatbelt_profile_extensions, + } = EffectiveSandboxPermissions::new( policy, macos_seatbelt_profile_extensions, - spec.additional_permissions.as_ref(), + additional_permissions.as_ref(), ); + let (effective_file_system_policy, effective_network_policy) = + if let Some(additional_permissions) = additional_permissions { + let (extra_reads, extra_writes) = + additional_permission_roots(&additional_permissions); + let file_system_sandbox_policy = + if extra_reads.is_empty() && extra_writes.is_empty() { + file_system_policy.clone() + } else { + merge_file_system_policy_with_additional_permissions( + file_system_policy, + extra_reads, + extra_writes, + ) + }; + let network_sandbox_policy = + if merge_network_access(network_policy.is_enabled(), &additional_permissions) { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + }; + (file_system_sandbox_policy, network_sandbox_policy) + } else { + (file_system_policy.clone(), network_policy) + }; let mut env = spec.env; - if !effective_permissions - .sandbox_policy - .has_full_network_access() - { + if !effective_network_policy.is_enabled() { env.insert( CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), "1".to_string(), @@ -388,13 +498,11 @@ impl SandboxManager { seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); let mut args = create_seatbelt_command_args_with_extensions( command.clone(), - &effective_permissions.sandbox_policy, + &effective_policy, sandbox_policy_cwd, enforce_managed_network, network, - effective_permissions - .macos_seatbelt_profile_extensions - .as_ref(), + _effective_macos_seatbelt_profile_extensions.as_ref(), ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); @@ -409,7 +517,7 @@ impl SandboxManager { let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); let mut args = create_linux_sandbox_command_args( command.clone(), - &effective_permissions.sandbox_policy, + &effective_policy, sandbox_policy_cwd, use_linux_sandbox_bwrap, allow_proxy_network, @@ -444,7 +552,9 @@ impl SandboxManager { sandbox, windows_sandbox_level, sandbox_permissions: spec.sandbox_permissions, - sandbox_policy: effective_permissions.sandbox_policy, + sandbox_policy: effective_policy, + file_system_sandbox_policy: effective_file_system_policy, + network_sandbox_policy: effective_network_policy, justification: spec.justification, arg0: arg0_override, }) @@ -477,9 +587,12 @@ mod tests { #[cfg(target_os = "macos")] use super::EffectiveSandboxPermissions; use super::SandboxManager; + use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; use super::sandbox_policy_with_additional_permissions; + use super::should_require_platform_sandbox; use crate::exec::SandboxType; + use crate::protocol::NetworkAccess; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::tools::sandboxing::SandboxablePreference; @@ -493,16 +606,24 @@ mod tests { use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSandboxPolicy; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; use pretty_assertions::assert_eq; + use std::collections::HashMap; use tempfile::TempDir; #[test] fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { let manager = SandboxManager::new(); let sandbox = manager.select_initial( - &SandboxPolicy::DangerFullAccess, + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Enabled, SandboxablePreference::Auto, WindowsSandboxLevel::Disabled, false, @@ -515,7 +636,8 @@ mod tests { let manager = SandboxManager::new(); let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); let sandbox = manager.select_initial( - &SandboxPolicy::DangerFullAccess, + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Enabled, SandboxablePreference::Auto, WindowsSandboxLevel::Disabled, true, @@ -523,6 +645,98 @@ mod tests { assert_eq!(sandbox, expected); } + #[test] + fn restricted_file_system_uses_platform_sandbox_without_managed_network() { + let manager = SandboxManager::new(); + let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }]), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + false, + ); + assert_eq!(sandbox, expected); + } + + #[test] + fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() { + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }]); + + assert_eq!( + should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Enabled, false), + false + ); + } + + #[test] + fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_network() { + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }]); + + assert_eq!( + should_require_platform_sandbox(&policy, NetworkSandboxPolicy::Restricted, false), + true + ); + } + + #[test] + fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let exec_request = manager + .transform(super::SandboxTransformRequest { + spec: super::CommandSpec { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + expiration: crate::exec::ExecExpiration::DefaultTimeout, + sandbox_permissions: super::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: None, + }, + policy: &SandboxPolicy::ExternalSandbox { + network_access: crate::protocol::NetworkAccess::Restricted, + }, + file_system_policy: &FileSystemSandboxPolicy::unrestricted(), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_linux_sandbox_bwrap: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .expect("transform"); + + assert_eq!( + exec_request.file_system_sandbox_policy, + FileSystemSandboxPolicy::unrestricted() + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); + } + #[test] fn normalize_additional_permissions_preserves_network() { let temp_dir = TempDir::new().expect("create temp dir"); @@ -624,7 +838,6 @@ mod tests { } ); } - #[cfg(target_os = "macos")] #[test] fn effective_permissions_merge_macos_extensions_with_additional_permissions() { @@ -679,4 +892,223 @@ mod tests { }) ); } + + #[test] + fn external_sandbox_additional_permissions_can_enable_network() { + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let policy = sandbox_policy_with_additional_permissions( + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + &PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![path]), + write: Some(Vec::new()), + }), + ..Default::default() + }, + ); + + assert_eq!( + policy, + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + } + ); + } + + #[test] + fn transform_additional_permissions_enable_network_for_external_sandbox() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let exec_request = manager + .transform(super::SandboxTransformRequest { + spec: super::CommandSpec { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + expiration: crate::exec::ExecExpiration::DefaultTimeout, + sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, + additional_permissions: Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![path]), + write: Some(Vec::new()), + }), + ..Default::default() + }), + justification: None, + }, + policy: &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + file_system_policy: &FileSystemSandboxPolicy::unrestricted(), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_linux_sandbox_bwrap: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .expect("transform"); + + assert_eq!( + exec_request.sandbox_policy, + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + } + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Enabled + ); + } + + #[test] + fn transform_additional_permissions_preserves_denied_entries() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let temp_dir = TempDir::new().expect("create temp dir"); + let workspace_root = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let allowed_path = workspace_root.join("allowed").expect("allowed path"); + let denied_path = workspace_root.join("denied").expect("denied path"); + let exec_request = manager + .transform(super::SandboxTransformRequest { + spec: super::CommandSpec { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + expiration: crate::exec::ExecExpiration::DefaultTimeout, + sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, + additional_permissions: Some(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: None, + write: Some(vec![allowed_path.clone()]), + }), + ..Default::default() + }), + justification: None, + }, + policy: &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }, + file_system_policy: &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_path.clone(), + }, + access: FileSystemAccessMode::None, + }, + ]), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_linux_sandbox_bwrap: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .expect("transform"); + + assert_eq!( + exec_request.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: denied_path }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: allowed_path }, + access: FileSystemAccessMode::Write, + }, + ]) + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); + } + + #[test] + fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() { + let temp_dir = TempDir::new().expect("create temp dir"); + let cwd = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let allowed_path = cwd.join("allowed").expect("allowed path"); + let denied_path = cwd.join("denied").expect("denied path"); + let merged_policy = merge_file_system_policy_with_additional_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_path.clone(), + }, + access: FileSystemAccessMode::None, + }, + ]), + vec![allowed_path.clone()], + Vec::new(), + ); + + assert_eq!( + merged_policy.entries.contains(&FileSystemSandboxEntry { + path: FileSystemPath::Path { path: denied_path }, + access: FileSystemAccessMode::None, + }), + true + ); + assert_eq!( + merged_policy.entries.contains(&FileSystemSandboxEntry { + path: FileSystemPath::Path { path: allowed_path }, + access: FileSystemAccessMode::Read, + }), + true + ); + } } diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 8d556a8eec..760c6bd8ae 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -22,6 +22,7 @@ use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; +use codex_protocol::permissions::NetworkSandboxPolicy; const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); @@ -51,7 +52,7 @@ pub async fn spawn_command_under_seatbelt( args, arg0, cwd: command_cwd, - sandbox_policy, + network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy), network, stdio_policy, env, diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index 67e6ace044..575a2f777c 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -6,13 +6,13 @@ use tokio::process::Child; use tokio::process::Command; use tracing::trace; -use crate::protocol::SandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; /// Experimental environment variable that will be set to some non-empty value /// if both of the following are true: /// /// 1. The process was spawned by Codex as part of a shell tool call. -/// 2. SandboxPolicy.has_full_network_access() was false for the tool call. +/// 2. NetworkSandboxPolicy is restricted for the tool call. /// /// We may try to have just one environment variable for all sandboxing /// attributes, so this may change in the future. @@ -33,15 +33,15 @@ pub enum StdioPolicy { /// ensuring the args and environment variables used to create the `Command` /// (and `Child`) honor the configuration. /// -/// For now, we take `SandboxPolicy` as a parameter to spawn_child() because -/// we need to determine whether to set the +/// For now, we take `NetworkSandboxPolicy` as a parameter to spawn_child() +/// because we need to determine whether to set the /// `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` environment variable. pub(crate) struct SpawnChildRequest<'a> { pub program: PathBuf, pub args: Vec, pub arg0: Option<&'a str>, pub cwd: PathBuf, - pub sandbox_policy: &'a SandboxPolicy, + pub network_sandbox_policy: NetworkSandboxPolicy, pub network: Option<&'a NetworkProxy>, pub stdio_policy: StdioPolicy, pub env: HashMap, @@ -53,14 +53,14 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io args, arg0, cwd, - sandbox_policy, + network_sandbox_policy, network, stdio_policy, mut env, } = request; trace!( - "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}" + "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {network_sandbox_policy:?} {stdio_policy:?} {env:?}" ); let mut cmd = Command::new(&program); @@ -74,7 +74,7 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io cmd.env_clear(); cmd.envs(env); - if !sandbox_policy.has_full_network_access() { + if !network_sandbox_policy.is_enabled() { cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1"); } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index a6a1301cd7..9357f154fc 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -36,6 +36,8 @@ use super::SessionTaskContext; use crate::codex::Session; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; const USER_SHELL_TIMEOUT_MS: u64 = 60 * 60 * 1000; // 1 hour @@ -167,6 +169,8 @@ pub(crate) async fn execute_user_shell_command( windows_sandbox_level: turn_context.windows_sandbox_level, sandbox_permissions: SandboxPermissions::UseDefault, sandbox_policy: sandbox_policy.clone(), + file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), + network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), justification: None, arg0: None, }; diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 42016ff9bf..f9cc3d854b 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -852,7 +852,8 @@ impl JsReplManager { .network .is_some(); let sandbox_type = sandbox.select_initial( - &turn.sandbox_policy, + &turn.file_system_sandbox_policy, + turn.network_sandbox_policy, SandboxablePreference::Auto, turn.windows_sandbox_level, has_managed_network_requirements, @@ -861,6 +862,8 @@ impl JsReplManager { .transform(crate::sandboxing::SandboxTransformRequest { spec, policy: &turn.sandbox_policy, + file_system_policy: &turn.file_system_sandbox_policy, + network_policy: turn.network_sandbox_policy, sandbox: sandbox_type, enforce_managed_network: has_managed_network_requirements, network: None, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index e78f0bcfce..d92ed5f621 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -169,7 +169,8 @@ impl ToolOrchestrator { let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) { SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None, SandboxOverride::NoOverride => self.sandbox.select_initial( - &turn_ctx.sandbox_policy, + &turn_ctx.file_system_sandbox_policy, + turn_ctx.network_sandbox_policy, tool.sandbox_preference(), turn_ctx.windows_sandbox_level, has_managed_network_requirements, @@ -182,6 +183,8 @@ impl ToolOrchestrator { let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, policy: &turn_ctx.sandbox_policy, + file_system_policy: &turn_ctx.file_system_sandbox_policy, + network_policy: turn_ctx.network_sandbox_policy, enforce_managed_network: has_managed_network_requirements, manager: &self.sandbox, sandbox_cwd: &turn_ctx.cwd, @@ -296,6 +299,8 @@ impl ToolOrchestrator { let escalated_attempt = SandboxAttempt { sandbox: crate::exec::SandboxType::None, policy: &turn_ctx.sandbox_policy, + file_system_policy: &turn_ctx.file_system_sandbox_policy, + network_policy: turn_ctx.network_sandbox_policy, enforce_managed_network: has_managed_network_requirements, manager: &self.sandbox, sandbox_cwd: &turn_ctx.cwd, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 28f4a65bea..99d7c14761 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -24,6 +24,8 @@ use codex_execpolicy::RuleMatch; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::RejectConfig; @@ -98,6 +100,8 @@ pub(super) async fn try_run_zsh_fork( windows_sandbox_level, sandbox_permissions, sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, justification, arg0, } = sandbox_exec_request; @@ -113,6 +117,8 @@ pub(super) async fn try_run_zsh_fork( command, cwd: sandbox_cwd, sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, sandbox, env: sandbox_env, network: sandbox_network, @@ -158,7 +164,9 @@ pub(super) async fn try_run_zsh_fork( turn: Arc::clone(&ctx.turn), call_id: ctx.call_id.clone(), approval_policy: ctx.turn.approval_policy.value(), - sandbox_policy: attempt.policy.clone(), + sandbox_policy: command_executor.sandbox_policy.clone(), + file_system_sandbox_policy: command_executor.file_system_sandbox_policy.clone(), + network_sandbox_policy: command_executor.network_sandbox_policy, sandbox_permissions: req.sandbox_permissions, prompt_permissions: req.additional_permissions.clone(), stopwatch: stopwatch.clone(), @@ -180,7 +188,7 @@ pub(super) async fn try_run_zsh_fork( pub(crate) async fn prepare_unified_exec_zsh_fork( req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest, - attempt: &SandboxAttempt<'_>, + _attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, exec_request: ExecRequest, ) -> Result, ToolError> { @@ -220,6 +228,8 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( command: exec_request.command.clone(), cwd: exec_request.cwd.clone(), sandbox_policy: exec_request.sandbox_policy.clone(), + file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(), + network_sandbox_policy: exec_request.network_sandbox_policy, sandbox: exec_request.sandbox, env: exec_request.env.clone(), network: exec_request.network.clone(), @@ -253,7 +263,9 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( turn: Arc::clone(&ctx.turn), call_id: ctx.call_id.clone(), approval_policy: ctx.turn.approval_policy.value(), - sandbox_policy: attempt.policy.clone(), + sandbox_policy: exec_request.sandbox_policy.clone(), + file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(), + network_sandbox_policy: exec_request.network_sandbox_policy, sandbox_permissions: req.sandbox_permissions, prompt_permissions: req.additional_permissions.clone(), stopwatch: Stopwatch::unlimited(), @@ -282,6 +294,8 @@ struct CoreShellActionProvider { call_id: String, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, sandbox_permissions: SandboxPermissions, prompt_permissions: Option, stopwatch: Stopwatch, @@ -308,6 +322,8 @@ impl CoreShellActionProvider { fn shell_request_escalation_execution( sandbox_permissions: SandboxPermissions, sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, additional_permissions: Option<&PermissionProfile>, macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>, ) -> EscalationExecution { @@ -321,6 +337,8 @@ impl CoreShellActionProvider { EscalationExecution::Permissions(EscalationPermissions::Permissions( EscalatedPermissions { sandbox_policy: sandbox_policy.clone(), + file_system_sandbox_policy: file_system_sandbox_policy.clone(), + network_sandbox_policy, macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions .cloned(), }, @@ -634,6 +652,8 @@ impl EscalationPolicy for CoreShellActionProvider { DecisionSource::UnmatchedCommandFallback => Self::shell_request_escalation_execution( self.sandbox_permissions, &self.sandbox_policy, + &self.file_system_sandbox_policy, + self.network_sandbox_policy, self.prompt_permissions.as_ref(), self.turn .config @@ -741,6 +761,8 @@ struct CoreShellCommandExecutor { command: Vec, cwd: PathBuf, sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, sandbox: SandboxType, env: HashMap, network: Option, @@ -760,6 +782,8 @@ struct PrepareSandboxedExecParams<'a> { workdir: &'a AbsolutePathBuf, env: HashMap, sandbox_policy: &'a SandboxPolicy, + file_system_sandbox_policy: &'a FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, additional_permissions: Option, #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, @@ -795,6 +819,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { windows_sandbox_level: self.windows_sandbox_level, sandbox_permissions: self.sandbox_permissions, sandbox_policy: self.sandbox_policy.clone(), + file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), + network_sandbox_policy: self.network_sandbox_policy, justification: self.justification.clone(), arg0: self.arg0.clone(), }, @@ -841,6 +867,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { workdir, env, sandbox_policy: &self.sandbox_policy, + file_system_sandbox_policy: &self.file_system_sandbox_policy, + network_sandbox_policy: self.network_sandbox_policy, additional_permissions: None, #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: self @@ -858,6 +886,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { workdir, env, sandbox_policy: &self.sandbox_policy, + file_system_sandbox_policy: &self.file_system_sandbox_policy, + network_sandbox_policy: self.network_sandbox_policy, additional_permissions: Some(permission_profile), #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: self @@ -872,6 +902,8 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { workdir, env, sandbox_policy: &permissions.sandbox_policy, + file_system_sandbox_policy: &permissions.file_system_sandbox_policy, + network_sandbox_policy: permissions.network_sandbox_policy, additional_permissions: None, #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: permissions @@ -886,6 +918,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { } impl CoreShellCommandExecutor { + #[allow(clippy::too_many_arguments)] fn prepare_sandboxed_exec( &self, params: PrepareSandboxedExecParams<'_>, @@ -895,6 +928,8 @@ impl CoreShellCommandExecutor { workdir, env, sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, additional_permissions, #[cfg(target_os = "macos")] macos_seatbelt_profile_extensions, @@ -904,7 +939,8 @@ impl CoreShellCommandExecutor { .ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?; let sandbox_manager = crate::sandboxing::SandboxManager::new(); let sandbox = sandbox_manager.select_initial( - sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, SandboxablePreference::Auto, self.windows_sandbox_level, self.network.is_some(), @@ -926,6 +962,8 @@ impl CoreShellCommandExecutor { justification: self.justification.clone(), }, policy: sandbox_policy, + file_system_policy: file_system_sandbox_policy, + network_policy: network_sandbox_policy, sandbox, enforce_managed_network: self.network.is_some(), network: self.network.as_ref(), diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 5d7b0f5e04..af71bd5e4e 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -31,6 +31,11 @@ use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::MacOsPreferencesPermission; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SkillScope; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; @@ -223,6 +228,21 @@ fn shell_request_escalation_execution_is_explicit() { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::from_absolute_path("/tmp/original/output").unwrap(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::from_absolute_path("/tmp/secret").unwrap(), + }, + access: FileSystemAccessMode::None, + }, + ]); + let network_sandbox_policy = NetworkSandboxPolicy::Restricted; let macos_seatbelt_profile_extensions = MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadWrite, ..Default::default() @@ -232,6 +252,8 @@ fn shell_request_escalation_execution_is_explicit() { CoreShellActionProvider::shell_request_escalation_execution( crate::sandboxing::SandboxPermissions::UseDefault, &sandbox_policy, + &file_system_sandbox_policy, + network_sandbox_policy, None, Some(&macos_seatbelt_profile_extensions), ), @@ -241,6 +263,8 @@ fn shell_request_escalation_execution_is_explicit() { CoreShellActionProvider::shell_request_escalation_execution( crate::sandboxing::SandboxPermissions::RequireEscalated, &sandbox_policy, + &file_system_sandbox_policy, + network_sandbox_policy, None, Some(&macos_seatbelt_profile_extensions), ), @@ -250,12 +274,16 @@ fn shell_request_escalation_execution_is_explicit() { CoreShellActionProvider::shell_request_escalation_execution( crate::sandboxing::SandboxPermissions::WithAdditionalPermissions, &sandbox_policy, + &file_system_sandbox_policy, + network_sandbox_policy, Some(&requested_permissions), Some(&macos_seatbelt_profile_extensions), ), EscalationExecution::Permissions(EscalationPermissions::Permissions( EscalatedPermissions { sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, macos_seatbelt_profile_extensions: Some(macos_seatbelt_profile_extensions), }, )), @@ -474,6 +502,10 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions network: None, sandbox: SandboxType::None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: FileSystemSandboxPolicy::from( + &SandboxPolicy::new_read_only_policy(), + ), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, justification: None, @@ -524,6 +556,8 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() network: None, sandbox: SandboxType::None, sandbox_policy: SandboxPolicy::DangerFullAccess, + file_system_sandbox_policy: FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), + network_sandbox_policy: NetworkSandboxPolicy::Enabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, justification: None, @@ -560,6 +594,8 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() EscalationExecution::Permissions(EscalationPermissions::Permissions( EscalatedPermissions { sandbox_policy: permissions.sandbox_policy.get().clone(), + file_system_sandbox_policy: permissions.file_system_sandbox_policy.clone(), + network_sandbox_policy: permissions.network_sandbox_policy, macos_seatbelt_profile_extensions: permissions .macos_seatbelt_profile_extensions .clone(), @@ -588,13 +624,16 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() #[tokio::test] async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_macos_extensions() { let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap(); + let sandbox_policy = SandboxPolicy::new_read_only_policy(); let executor = CoreShellCommandExecutor { command: vec!["echo".to_string(), "ok".to_string()], cwd: cwd.to_path_buf(), env: HashMap::new(), network: None, sandbox: SandboxType::None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + sandbox_policy: sandbox_policy.clone(), + file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), + network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, justification: None, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index df8f7c152b..b06b329540 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -17,6 +17,8 @@ use crate::tools::network_approval::NetworkApprovalSpec; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use futures::Future; @@ -318,6 +320,8 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { pub(crate) struct SandboxAttempt<'a> { pub sandbox: crate::exec::SandboxType, pub policy: &'a crate::protocol::SandboxPolicy, + pub file_system_policy: &'a FileSystemSandboxPolicy, + pub network_policy: NetworkSandboxPolicy, pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a Path, @@ -336,6 +340,8 @@ impl<'a> SandboxAttempt<'a> { .transform(crate::sandboxing::SandboxTransformRequest { spec, policy: self.policy, + file_system_policy: self.file_system_policy, + network_policy: self.network_policy, sandbox: self.sandbox, enforce_managed_network: self.enforce_managed_network, network, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 6323857150..2e46d6a943 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -205,6 +205,10 @@ mod tests { turn.sandbox_policy .set(SandboxPolicy::DangerFullAccess) .expect("test setup should allow updating sandbox policy"); + turn.file_system_sandbox_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from(turn.sandbox_policy.get()); + turn.network_sandbox_policy = + codex_protocol::permissions::NetworkSandboxPolicy::from(turn.sandbox_policy.get()); (Arc::new(session), Arc::new(turn)) } diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 6546600681..e00ec963df 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -10,6 +10,8 @@ use codex_core::exec::process_exec_tool_call; use codex_core::sandboxing::SandboxPermissions; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; use tempfile::TempDir; @@ -45,7 +47,17 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result anyh Ok(()) } +#[tokio::test] +async fn user_shell_command_does_not_set_network_sandbox_env_var() -> anyhow::Result<()> { + let server = responses::start_mock_server().await; + let mut builder = core_test_support::test_codex::test_codex().with_config(|config| { + config.permissions.network_sandbox_policy = NetworkSandboxPolicy::Restricted; + }); + let test = builder.build(&server).await?; + + #[cfg(windows)] + let command = r#"$val = $env:CODEX_SANDBOX_NETWORK_DISABLED; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string(); + #[cfg(not(windows))] + let command = + r#"sh -c "printf '%s' \"${CODEX_SANDBOX_NETWORK_DISABLED:-not-set}\"""#.to_string(); + + test.codex + .submit(Op::RunUserShellCommand { command }) + .await?; + + let end_event = wait_for_event_match(&test.codex, |ev| match ev { + EventMsg::ExecCommandEnd(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!(end_event.exit_code, 0); + assert_eq!(end_event.stdout.trim(), "not-set"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[cfg(not(target_os = "windows"))] // TODO: unignore on windows async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<()> { diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 362fcaf35f..7602108145 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -9,6 +9,8 @@ use codex_core::exec::process_exec_tool_call; use codex_core::exec_env::create_env; use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -102,6 +104,8 @@ async fn run_cmd_result_with_writable_roots( process_exec_tool_call( params, &sandbox_policy, + &FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), sandbox_cwd.as_path(), &codex_linux_sandbox_exe, use_bwrap_sandbox, @@ -333,6 +337,8 @@ async fn assert_network_blocked(cmd: &[&str]) { let result = process_exec_tool_call( params, &sandbox_policy, + &FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), sandbox_cwd.as_path(), &codex_linux_sandbox_exe, false, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 680462b720..4116777afb 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -5,6 +5,8 @@ use crate::mcp::RequestId; use crate::models::MacOsSeatbeltProfileExtensions; use crate::models::PermissionProfile; use crate::parse_command::ParsedCommand; +use crate::permissions::FileSystemSandboxPolicy; +use crate::permissions::NetworkSandboxPolicy; use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; @@ -17,6 +19,8 @@ use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Permissions { pub sandbox_policy: SandboxPolicy, + pub file_system_sandbox_policy: FileSystemSandboxPolicy, + pub network_sandbox_policy: NetworkSandboxPolicy, pub macos_seatbelt_profile_extensions: Option, }