diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index d2c2cfdba7..d79ea9e998 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -49,7 +49,10 @@ jobs: needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: 60 + # Codex binaries can take a long time to build, particularly on Windows. + # Ideally, this would be 60 minutes, but let's add some headroom because + # having to restart a release build due to a timeout is a major pain. + timeout-minutes: 90 permissions: contents: read id-token: write diff --git a/AGENTS.md b/AGENTS.md index 23f0bab387..6939d146b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,12 @@ In the codex-rs folder where the rust code lives: - You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not. - When possible, make `match` statements exhaustive and avoid wildcard arms. - Newly added traits should include doc comments that explain their role and how implementations are expected to use them. +- Discourage both `#[async_trait]` and `#[allow(async_fn_in_trait)]` in Rust traits. + - Prefer native RPITIT trait methods with explicit `Send` bounds on the returned future, as in `3c7f013f9735` / `#16630`. + - Preferred trait shape: + `fn foo(&self, ...) -> impl std::future::Future + Send;` + - Implementations may still use `async fn foo(&self, ...) -> T` when they satisfy that contract. + - Do not use `#[allow(async_fn_in_trait)]` as a shortcut around spelling the future contract explicitly. - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. - Prefer private modules and explicitly exported public crate API. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a37e13bbfe..d8744c4373 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1748,6 +1748,21 @@ dependencies = [ "unicode-width 0.2.1", ] +[[package]] +name = "codex-agent-graph-store" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-protocol", + "codex-state", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "codex-agent-identity" version = "0.0.0" @@ -1859,6 +1874,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-git-utils", + "codex-hooks", "codex-login", "codex-mcp", "codex-memories-write", @@ -2464,6 +2480,23 @@ dependencies = [ "zstd 0.13.3", ] +[[package]] +name = "codex-core-api" +version = "0.0.0" +dependencies = [ + "codex-analytics", + "codex-arg0", + "codex-config", + "codex-core", + "codex-exec-server", + "codex-features", + "codex-login", + "codex-model-provider-info", + "codex-models-manager", + "codex-protocol", + "codex-utils-absolute-path", +] + [[package]] name = "codex-core-plugins" version = "0.0.0" @@ -2944,9 +2977,7 @@ dependencies = [ "codex-config", "codex-core", "codex-exec-server", - "codex-features", "codex-login", - "codex-models-manager", "codex-protocol", "codex-shell-command", "codex-utils-absolute-path", @@ -3483,18 +3514,7 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", - "codex-arg0", - "codex-config", - "codex-core", - "codex-exec-server", - "codex-features", - "codex-login", - "codex-model-provider-info", - "codex-models-manager", - "codex-protocol", - "codex-rollout", - "codex-thread-store", - "codex-utils-absolute-path", + "codex-core-api", "tracing", ] @@ -3856,6 +3876,8 @@ version = "0.0.0" dependencies = [ "pretty_assertions", "regex-lite", + "serde", + "serde_json", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 217c179be9..79d932c8be 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -2,6 +2,7 @@ members = [ "aws-auth", "analytics", + "agent-graph-store", "agent-identity", "backend-client", "ansi-escape", @@ -31,6 +32,7 @@ members = [ "shell-escalation", "skills", "core", + "core-api", "core-plugins", "core-skills", "hooks", @@ -119,6 +121,7 @@ license = "Apache-2.0" # Internal app_test_support = { path = "app-server/tests/common" } codex-analytics = { path = "analytics" } +codex-agent-graph-store = { path = "agent-graph-store" } codex-agent-identity = { path = "agent-identity" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } @@ -142,6 +145,7 @@ codex-code-mode = { path = "code-mode" } codex-config = { path = "config" } codex-connectors = { path = "connectors" } codex-core = { path = "core" } +codex-core-api = { path = "core-api" } codex-core-plugins = { path = "core-plugins" } codex-core-skills = { path = "core-skills" } codex-device-key = { path = "device-key" } @@ -450,6 +454,7 @@ unwrap_used = "deny" # silence the false positive here instead of deleting a real dependency. [workspace.metadata.cargo-shear] ignored = [ + "codex-agent-graph-store", "icu_provider", "openssl-sys", "codex-utils-readiness", diff --git a/codex-rs/agent-graph-store/BUILD.bazel b/codex-rs/agent-graph-store/BUILD.bazel new file mode 100644 index 0000000000..96c077e263 --- /dev/null +++ b/codex-rs/agent-graph-store/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "agent-graph-store", + crate_name = "codex_agent_graph_store", +) diff --git a/codex-rs/agent-graph-store/Cargo.toml b/codex-rs/agent-graph-store/Cargo.toml new file mode 100644 index 0000000000..e221ef61b2 --- /dev/null +++ b/codex-rs/agent-graph-store/Cargo.toml @@ -0,0 +1,25 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-agent-graph-store" +version.workspace = true + +[lib] +name = "codex_agent_graph_store" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-protocol = { workspace = true } +codex-state = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/agent-graph-store/src/error.rs b/codex-rs/agent-graph-store/src/error.rs new file mode 100644 index 0000000000..ddd8eeef38 --- /dev/null +++ b/codex-rs/agent-graph-store/src/error.rs @@ -0,0 +1,20 @@ +/// Result type returned by agent graph store operations. +pub type AgentGraphStoreResult = Result; + +/// Error type shared by agent graph store implementations. +#[derive(Debug, thiserror::Error)] +pub enum AgentGraphStoreError { + /// The caller supplied invalid request data. + #[error("invalid agent graph store request: {message}")] + InvalidRequest { + /// User-facing explanation of the invalid request. + message: String, + }, + + /// Catch-all for implementation failures that do not fit a more specific category. + #[error("agent graph store internal error: {message}")] + Internal { + /// User-facing explanation of the implementation failure. + message: String, + }, +} diff --git a/codex-rs/agent-graph-store/src/lib.rs b/codex-rs/agent-graph-store/src/lib.rs new file mode 100644 index 0000000000..72e8b45e84 --- /dev/null +++ b/codex-rs/agent-graph-store/src/lib.rs @@ -0,0 +1,12 @@ +//! Storage-neutral parent/child topology for thread-spawned agents. + +mod error; +mod local; +mod store; +mod types; + +pub use error::AgentGraphStoreError; +pub use error::AgentGraphStoreResult; +pub use local::LocalAgentGraphStore; +pub use store::AgentGraphStore; +pub use types::ThreadSpawnEdgeStatus; diff --git a/codex-rs/agent-graph-store/src/local.rs b/codex-rs/agent-graph-store/src/local.rs new file mode 100644 index 0000000000..f45874855c --- /dev/null +++ b/codex-rs/agent-graph-store/src/local.rs @@ -0,0 +1,325 @@ +use async_trait::async_trait; +use codex_protocol::ThreadId; +use codex_state::StateRuntime; +use std::sync::Arc; + +use crate::AgentGraphStore; +use crate::AgentGraphStoreError; +use crate::AgentGraphStoreResult; +use crate::ThreadSpawnEdgeStatus; + +/// SQLite-backed implementation of [`AgentGraphStore`] using an existing state runtime. +#[derive(Clone)] +pub struct LocalAgentGraphStore { + state_db: Arc, +} + +impl std::fmt::Debug for LocalAgentGraphStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LocalAgentGraphStore") + .field("codex_home", &self.state_db.codex_home()) + .finish_non_exhaustive() + } +} + +impl LocalAgentGraphStore { + /// Create a local graph store from an already-initialized state runtime. + pub fn new(state_db: Arc) -> Self { + Self { state_db } + } +} + +#[async_trait] +impl AgentGraphStore for LocalAgentGraphStore { + async fn upsert_thread_spawn_edge( + &self, + parent_thread_id: ThreadId, + child_thread_id: ThreadId, + status: ThreadSpawnEdgeStatus, + ) -> AgentGraphStoreResult<()> { + self.state_db + .upsert_thread_spawn_edge(parent_thread_id, child_thread_id, to_state_status(status)) + .await + .map_err(internal_error) + } + + async fn set_thread_spawn_edge_status( + &self, + child_thread_id: ThreadId, + status: ThreadSpawnEdgeStatus, + ) -> AgentGraphStoreResult<()> { + self.state_db + .set_thread_spawn_edge_status(child_thread_id, to_state_status(status)) + .await + .map_err(internal_error) + } + + async fn list_thread_spawn_children( + &self, + parent_thread_id: ThreadId, + status_filter: Option, + ) -> AgentGraphStoreResult> { + if let Some(status) = status_filter { + return self + .state_db + .list_thread_spawn_children_with_status(parent_thread_id, to_state_status(status)) + .await + .map_err(internal_error); + } + + self.state_db + .list_thread_spawn_children(parent_thread_id) + .await + .map_err(internal_error) + } + + async fn list_thread_spawn_descendants( + &self, + root_thread_id: ThreadId, + status_filter: Option, + ) -> AgentGraphStoreResult> { + match status_filter { + Some(status) => self + .state_db + .list_thread_spawn_descendants_with_status(root_thread_id, to_state_status(status)) + .await + .map_err(internal_error), + None => self + .state_db + .list_thread_spawn_descendants(root_thread_id) + .await + .map_err(internal_error), + } + } +} + +fn to_state_status(status: ThreadSpawnEdgeStatus) -> codex_state::DirectionalThreadSpawnEdgeStatus { + match status { + ThreadSpawnEdgeStatus::Open => codex_state::DirectionalThreadSpawnEdgeStatus::Open, + ThreadSpawnEdgeStatus::Closed => codex_state::DirectionalThreadSpawnEdgeStatus::Closed, + } +} + +fn internal_error(err: impl std::fmt::Display) -> AgentGraphStoreError { + AgentGraphStoreError::Internal { + message: err.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_state::DirectionalThreadSpawnEdgeStatus; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + struct TestRuntime { + state_db: Arc, + _codex_home: TempDir, + } + + fn thread_id(suffix: u128) -> ThreadId { + ThreadId::from_string(&format!("00000000-0000-0000-0000-{suffix:012}")) + .expect("valid thread id") + } + + async fn state_runtime() -> TestRuntime { + let codex_home = TempDir::new().expect("tempdir should be created"); + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + TestRuntime { + state_db, + _codex_home: codex_home, + } + } + + #[tokio::test] + async fn local_store_upserts_and_lists_direct_children_with_status_filters() { + let fixture = state_runtime().await; + let state_db = fixture.state_db; + let store = LocalAgentGraphStore::new(state_db.clone()); + let parent_thread_id = thread_id(/*suffix*/ 1); + let first_child_thread_id = thread_id(/*suffix*/ 2); + let second_child_thread_id = thread_id(/*suffix*/ 3); + + store + .upsert_thread_spawn_edge( + parent_thread_id, + second_child_thread_id, + ThreadSpawnEdgeStatus::Closed, + ) + .await + .expect("closed child edge should insert"); + store + .upsert_thread_spawn_edge( + parent_thread_id, + first_child_thread_id, + ThreadSpawnEdgeStatus::Open, + ) + .await + .expect("open child edge should insert"); + + let all_children = store + .list_thread_spawn_children(parent_thread_id, /*status_filter*/ None) + .await + .expect("all children should load"); + assert_eq!( + all_children, + vec![first_child_thread_id, second_child_thread_id] + ); + + let open_children = store + .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Open)) + .await + .expect("open children should load"); + let state_open_children = state_db + .list_thread_spawn_children_with_status( + parent_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + .expect("state open children should load"); + assert_eq!(open_children, state_open_children); + assert_eq!(open_children, vec![first_child_thread_id]); + + let closed_children = store + .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Closed)) + .await + .expect("closed children should load"); + assert_eq!(closed_children, vec![second_child_thread_id]); + } + + #[tokio::test] + async fn local_store_updates_edge_status() { + let fixture = state_runtime().await; + let state_db = fixture.state_db; + let store = LocalAgentGraphStore::new(state_db); + let parent_thread_id = thread_id(/*suffix*/ 10); + let child_thread_id = thread_id(/*suffix*/ 11); + + store + .upsert_thread_spawn_edge( + parent_thread_id, + child_thread_id, + ThreadSpawnEdgeStatus::Open, + ) + .await + .expect("child edge should insert"); + store + .set_thread_spawn_edge_status(child_thread_id, ThreadSpawnEdgeStatus::Closed) + .await + .expect("child edge should close"); + + let open_children = store + .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Open)) + .await + .expect("open children should load"); + assert_eq!(open_children, Vec::::new()); + + let closed_children = store + .list_thread_spawn_children(parent_thread_id, Some(ThreadSpawnEdgeStatus::Closed)) + .await + .expect("closed children should load"); + assert_eq!(closed_children, vec![child_thread_id]); + } + + #[tokio::test] + async fn local_store_lists_descendants_breadth_first_with_status_filters() { + let fixture = state_runtime().await; + let state_db = fixture.state_db; + let store = LocalAgentGraphStore::new(state_db.clone()); + let root_thread_id = thread_id(/*suffix*/ 20); + let later_child_thread_id = thread_id(/*suffix*/ 22); + let earlier_child_thread_id = thread_id(/*suffix*/ 21); + let closed_grandchild_thread_id = thread_id(/*suffix*/ 23); + let open_grandchild_thread_id = thread_id(/*suffix*/ 24); + let closed_child_thread_id = thread_id(/*suffix*/ 25); + let closed_great_grandchild_thread_id = thread_id(/*suffix*/ 26); + + for (parent_thread_id, child_thread_id, status) in [ + ( + root_thread_id, + later_child_thread_id, + ThreadSpawnEdgeStatus::Open, + ), + ( + root_thread_id, + earlier_child_thread_id, + ThreadSpawnEdgeStatus::Open, + ), + ( + earlier_child_thread_id, + open_grandchild_thread_id, + ThreadSpawnEdgeStatus::Open, + ), + ( + later_child_thread_id, + closed_grandchild_thread_id, + ThreadSpawnEdgeStatus::Closed, + ), + ( + root_thread_id, + closed_child_thread_id, + ThreadSpawnEdgeStatus::Closed, + ), + ( + closed_child_thread_id, + closed_great_grandchild_thread_id, + ThreadSpawnEdgeStatus::Closed, + ), + ] { + store + .upsert_thread_spawn_edge(parent_thread_id, child_thread_id, status) + .await + .expect("edge should insert"); + } + + let all_descendants = store + .list_thread_spawn_descendants(root_thread_id, /*status_filter*/ None) + .await + .expect("all descendants should load"); + assert_eq!( + all_descendants, + vec![ + earlier_child_thread_id, + later_child_thread_id, + closed_child_thread_id, + closed_grandchild_thread_id, + open_grandchild_thread_id, + closed_great_grandchild_thread_id, + ] + ); + + let open_descendants = store + .list_thread_spawn_descendants(root_thread_id, Some(ThreadSpawnEdgeStatus::Open)) + .await + .expect("open descendants should load"); + let state_open_descendants = state_db + .list_thread_spawn_descendants_with_status( + root_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + .expect("state open descendants should load"); + assert_eq!(open_descendants, state_open_descendants); + assert_eq!( + open_descendants, + vec![ + earlier_child_thread_id, + later_child_thread_id, + open_grandchild_thread_id, + ] + ); + + let closed_descendants = store + .list_thread_spawn_descendants(root_thread_id, Some(ThreadSpawnEdgeStatus::Closed)) + .await + .expect("closed descendants should load"); + assert_eq!( + closed_descendants, + vec![closed_child_thread_id, closed_great_grandchild_thread_id] + ); + } +} diff --git a/codex-rs/agent-graph-store/src/store.rs b/codex-rs/agent-graph-store/src/store.rs new file mode 100644 index 0000000000..c421182110 --- /dev/null +++ b/codex-rs/agent-graph-store/src/store.rs @@ -0,0 +1,55 @@ +use async_trait::async_trait; +use codex_protocol::ThreadId; + +use crate::AgentGraphStoreResult; +use crate::ThreadSpawnEdgeStatus; + +/// Storage-neutral boundary for persisted thread-spawn parent/child topology. +/// +/// Implementations are expected to return stable ordering for list methods so callers can merge +/// persisted graph state with live in-memory state without introducing nondeterministic output. +#[async_trait] +pub trait AgentGraphStore: Send + Sync { + /// Insert or replace the directional parent/child edge for a spawned thread. + /// + /// `child_thread_id` has at most one persisted parent. Re-inserting the same child should + /// update both the parent and status to match the supplied values. + async fn upsert_thread_spawn_edge( + &self, + parent_thread_id: ThreadId, + child_thread_id: ThreadId, + status: ThreadSpawnEdgeStatus, + ) -> AgentGraphStoreResult<()>; + + /// Update the persisted lifecycle status of a spawned thread's incoming edge. + /// + /// Implementations should treat missing children as a successful no-op. + async fn set_thread_spawn_edge_status( + &self, + child_thread_id: ThreadId, + status: ThreadSpawnEdgeStatus, + ) -> AgentGraphStoreResult<()>; + + /// List direct spawned children of a parent thread. + /// + /// When `status_filter` is `Some`, only child edges with that exact status are returned. When + /// it is `None`, all direct child edges are returned regardless of status, including statuses + /// that may be added by a future store implementation. + async fn list_thread_spawn_children( + &self, + parent_thread_id: ThreadId, + status_filter: Option, + ) -> AgentGraphStoreResult>; + + /// List spawned descendants breadth-first by depth, then by thread id. + /// + /// `status_filter` is applied to every traversed edge, not just to the returned descendants. + /// For example, `Some(Open)` walks only open edges, so descendants under a closed edge are not + /// included even if their own incoming edge is open. `None` walks and returns every persisted + /// edge regardless of status. + async fn list_thread_spawn_descendants( + &self, + root_thread_id: ThreadId, + status_filter: Option, + ) -> AgentGraphStoreResult>; +} diff --git a/codex-rs/agent-graph-store/src/types.rs b/codex-rs/agent-graph-store/src/types.rs new file mode 100644 index 0000000000..2a9f6caedb --- /dev/null +++ b/codex-rs/agent-graph-store/src/types.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; +use serde::Serialize; + +/// Lifecycle status attached to a directional thread-spawn edge. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadSpawnEdgeStatus { + /// The child thread is still live or resumable as an open spawned agent. + Open, + /// The child thread has been closed from the parent/child graph's perspective. + Closed, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn thread_spawn_edge_status_serializes_as_snake_case() { + assert_eq!( + serde_json::to_string(&ThreadSpawnEdgeStatus::Open) + .expect("open status should serialize"), + "\"open\"" + ); + assert_eq!( + serde_json::to_string(&ThreadSpawnEdgeStatus::Closed) + .expect("closed status should serialize"), + "\"closed\"" + ); + assert_eq!( + serde_json::from_str::("\"open\"") + .expect("open status should deserialize"), + ThreadSpawnEdgeStatus::Open + ); + assert_eq!( + serde_json::from_str::("\"closed\"") + .expect("closed status should deserialize"), + ThreadSpawnEdgeStatus::Closed + ); + } +} diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 9f88ceeb94..0b5fc3b87b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -59,18 +59,19 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NonSteerableTurnKind; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; @@ -141,27 +142,25 @@ fn sample_thread_with_source( } } -fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) -> ClientResponse { - ClientResponse::ThreadStart { - request_id: RequestId::Integer(1), - response: ThreadStartResponse { - thread: sample_thread(thread_id, ephemeral), - model: model.to_string(), - model_provider: "openai".to_string(), - service_tier: None, - cwd: test_path_buf("/tmp").abs(), - instruction_sources: Vec::new(), - approval_policy: AppServerAskForApproval::OnFailure, - approvals_reviewer: AppServerApprovalsReviewer::User, - sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), - reasoning_effort: None, - }, - } -} - -fn sample_permission_profile() -> AppServerPermissionProfile { - CorePermissionProfile::Disabled.into() +fn sample_thread_start_response( + thread_id: &str, + ephemeral: bool, + model: &str, +) -> ClientResponsePayload { + ClientResponsePayload::ThreadStart(ThreadStartResponse { + thread: sample_thread(thread_id, ephemeral), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: None, + active_permission_profile: None, + reasoning_effort: None, + }) } fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { @@ -183,7 +182,11 @@ fn sample_runtime_metadata() -> CodexRuntimeMetadata { } } -fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) -> ClientResponse { +fn sample_thread_resume_response( + thread_id: &str, + ephemeral: bool, + model: &str, +) -> ClientResponsePayload { sample_thread_resume_response_with_source( thread_id, ephemeral, @@ -197,23 +200,21 @@ fn sample_thread_resume_response_with_source( ephemeral: bool, model: &str, source: AppServerSessionSource, -) -> ClientResponse { - ClientResponse::ThreadResume { - request_id: RequestId::Integer(2), - response: ThreadResumeResponse { - thread: sample_thread_with_source(thread_id, ephemeral, source), - model: model.to_string(), - model_provider: "openai".to_string(), - service_tier: None, - cwd: test_path_buf("/tmp").abs(), - instruction_sources: Vec::new(), - approval_policy: AppServerAskForApproval::OnFailure, - approvals_reviewer: AppServerApprovalsReviewer::User, - sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), - reasoning_effort: None, - }, - } +) -> ClientResponsePayload { + ClientResponsePayload::ThreadResume(ThreadResumeResponse { + thread: sample_thread_with_source(thread_id, ephemeral, source), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: None, + active_permission_profile: None, + reasoning_effort: None, + }) } fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest { @@ -235,21 +236,18 @@ fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest } } -fn sample_turn_start_response(turn_id: &str, request_id: i64) -> ClientResponse { - ClientResponse::TurnStart { - request_id: RequestId::Integer(request_id), - response: codex_app_server_protocol::TurnStartResponse { - turn: Turn { - id: turn_id.to_string(), - items: vec![], - status: AppServerTurnStatus::InProgress, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - }, +fn sample_turn_start_response(turn_id: &str) -> ClientResponsePayload { + ClientResponsePayload::TurnStart(codex_app_server_protocol::TurnStartResponse { + turn: Turn { + id: turn_id.to_string(), + items: vec![], + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, }, - } + }) } fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNotification { @@ -355,13 +353,10 @@ fn sample_turn_steer_request( } } -fn sample_turn_steer_response(turn_id: &str, request_id: i64) -> ClientResponse { - ClientResponse::TurnSteer { - request_id: RequestId::Integer(request_id), - response: TurnSteerResponse { - turn_id: turn_id.to_string(), - }, - } +fn sample_turn_steer_response(turn_id: &str) -> ClientResponsePayload { + ClientResponsePayload::TurnSteer(TurnSteerResponse { + turn_id: turn_id.to_string(), + }) } fn no_active_turn_steer_error() -> JSONRPCErrorError { @@ -488,6 +483,7 @@ async fn ingest_turn_prerequisites( .ingest( AnalyticsFact::ClientResponse { connection_id: 7, + request_id: RequestId::Integer(1), response: Box::new(sample_thread_start_response( "thread-2", /*ephemeral*/ false, "gpt-5", )), @@ -512,7 +508,8 @@ async fn ingest_turn_prerequisites( .ingest( AnalyticsFact::ClientResponse { connection_id: 7, - response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + request_id: RequestId::Integer(3), + response: Box::new(sample_turn_start_response("turn-2")), }, out, ) @@ -864,6 +861,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize .ingest( AnalyticsFact::ClientResponse { connection_id: 7, + request_id: RequestId::Integer(1), response: Box::new(sample_thread_start_response( "thread-no-client", /*ephemeral*/ false, @@ -908,6 +906,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize .ingest( AnalyticsFact::ClientResponse { connection_id: 7, + request_id: RequestId::Integer(2), response: Box::new(sample_thread_resume_response( "thread-1", /*ephemeral*/ true, "gpt-5", )), @@ -954,6 +953,65 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize ); } +#[tokio::test] +async fn unrelated_client_requests_are_ignored_by_reducer() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::ClientRequest { + connection_id: 7, + request_id: RequestId::Integer(3), + request: Box::new(ClientRequest::ThreadArchive { + request_id: RequestId::Integer(3), + params: ThreadArchiveParams { + thread_id: "thread-2".to_string(), + }, + }), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(3), + response: Box::new(sample_turn_start_response("turn-2")), + }, + &mut events, + ) + .await; + + assert!( + events.is_empty(), + "unrelated requests must not create pending turn state" + ); +} + +#[tokio::test] +async fn unrelated_client_responses_are_ignored_by_reducer() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_initialize(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(9), + response: Box::new(ClientResponsePayload::ThreadArchive( + ThreadArchiveResponse {}, + )), + }, + &mut events, + ) + .await; + + assert!(events.is_empty()); +} + #[tokio::test] async fn compaction_event_ingests_custom_fact() { let mut reducer = AnalyticsReducer::default(); @@ -988,6 +1046,7 @@ async fn compaction_event_ingests_custom_fact() { .ingest( AnalyticsFact::ClientResponse { connection_id: 7, + request_id: RequestId::Integer(2), response: Box::new(sample_thread_resume_response_with_source( "thread-1", /*ephemeral*/ false, @@ -1099,6 +1158,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { .ingest( AnalyticsFact::ClientResponse { connection_id: 7, + request_id: RequestId::Integer(1), response: Box::new(sample_thread_start_response( "thread-guardian", /*ephemeral*/ false, @@ -1881,7 +1941,8 @@ async fn accepted_turn_steer_emits_expected_event() { .ingest( AnalyticsFact::ClientResponse { connection_id: 7, - response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + request_id: RequestId::Integer(4), + response: Box::new(sample_turn_steer_response("turn-2")), }, &mut out, ) @@ -2047,7 +2108,8 @@ async fn turn_start_error_response_discards_pending_start_request() { .ingest( AnalyticsFact::ClientResponse { connection_id: 7, - response: Box::new(sample_turn_start_response("turn-2", /*request_id*/ 3)), + request_id: RequestId::Integer(3), + response: Box::new(sample_turn_start_response("turn-2")), }, &mut out, ) @@ -2176,7 +2238,8 @@ async fn accepted_steers_increment_turn_steer_count() { .ingest( AnalyticsFact::ClientResponse { connection_id: 7, - response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 4)), + request_id: RequestId::Integer(4), + response: Box::new(sample_turn_steer_response("turn-2")), }, &mut out, ) @@ -2222,7 +2285,8 @@ async fn accepted_steers_increment_turn_steer_count() { .ingest( AnalyticsFact::ClientResponse { connection_id: 7, - response: Box::new(sample_turn_steer_response("turn-2", /*request_id*/ 6)), + request_id: RequestId::Integer(6), + response: Box::new(sample_turn_steer_response("turn-2")), }, &mut out, ) diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index b149614353..d54c53ede9 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -22,7 +22,7 @@ use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; @@ -186,11 +186,22 @@ impl AnalyticsEventsClient { ))); } - pub fn track_request(&self, connection_id: u64, request_id: RequestId, request: ClientRequest) { + pub fn track_request( + &self, + connection_id: u64, + request_id: RequestId, + request: &ClientRequest, + ) { + if !matches!( + request, + ClientRequest::TurnStart { .. } | ClientRequest::TurnSteer { .. } + ) { + return; + } self.record_fact(AnalyticsFact::ClientRequest { connection_id, request_id, - request: Box::new(request), + request: Box::new(request.clone()), }); } @@ -284,9 +295,25 @@ impl AnalyticsEventsClient { } } - pub fn track_response(&self, connection_id: u64, response: ClientResponse) { + pub fn track_response( + &self, + connection_id: u64, + request_id: RequestId, + response: ClientResponsePayload, + ) { + if !matches!( + response, + ClientResponsePayload::ThreadStart(_) + | ClientResponsePayload::ThreadResume(_) + | ClientResponsePayload::ThreadFork(_) + | ClientResponsePayload::TurnStart(_) + | ClientResponsePayload::TurnSteer(_) + ) { + return; + } self.record_fact(AnalyticsFact::ClientResponse { connection_id, + request_id, response: Box::new(response), }); } @@ -364,3 +391,7 @@ async fn send_track_events( } } } + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs new file mode 100644 index 0000000000..4b6fb54e95 --- /dev/null +++ b/codex-rs/analytics/src/client_tests.rs @@ -0,0 +1,221 @@ +use super::AnalyticsEventsClient; +use super::AnalyticsEventsQueue; +use crate::facts::AnalyticsFact; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::SessionSource as AppServerSessionSource; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TryRecvError; + +fn client_with_receiver() -> (AnalyticsEventsClient, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(8); + let queue = AnalyticsEventsQueue { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + }; + (AnalyticsEventsClient { queue: Some(queue) }, receiver) +} + +fn sample_turn_start_request() -> ClientRequest { + ClientRequest::TurnStart { + request_id: RequestId::Integer(1), + params: TurnStartParams { + thread_id: "thread-1".to_string(), + input: Vec::new(), + ..Default::default() + }, + } +} + +fn sample_turn_steer_request() -> ClientRequest { + ClientRequest::TurnSteer { + request_id: RequestId::Integer(2), + params: TurnSteerParams { + thread_id: "thread-1".to_string(), + expected_turn_id: "turn-1".to_string(), + input: Vec::new(), + responsesapi_client_metadata: None, + }, + } +} + +fn sample_thread_archive_request() -> ClientRequest { + ClientRequest::ThreadArchive { + request_id: RequestId::Integer(3), + params: ThreadArchiveParams { + thread_id: "thread-1".to_string(), + }, + } +} + +fn sample_thread(thread_id: &str) -> Thread { + Thread { + id: thread_id.to_string(), + forked_from_id: None, + preview: "first prompt".to_string(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 2, + status: AppServerThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: "0.0.0".to_string(), + source: AppServerSessionSource::Exec, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + } +} + +fn sample_permission_profile() -> AppServerPermissionProfile { + CorePermissionProfile::Disabled.into() +} + +fn sample_thread_start_response() -> ClientResponsePayload { + ClientResponsePayload::ThreadStart(ThreadStartResponse { + thread: sample_thread("thread-1"), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, + reasoning_effort: None, + }) +} + +fn sample_thread_resume_response() -> ClientResponsePayload { + ClientResponsePayload::ThreadResume(ThreadResumeResponse { + thread: sample_thread("thread-2"), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, + reasoning_effort: None, + }) +} + +fn sample_thread_fork_response() -> ClientResponsePayload { + ClientResponsePayload::ThreadFork(ThreadForkResponse { + thread: sample_thread("thread-3"), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_path_buf("/tmp").abs(), + instruction_sources: Vec::new(), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: Some(sample_permission_profile()), + active_permission_profile: None, + reasoning_effort: None, + }) +} + +fn sample_turn_start_response() -> ClientResponsePayload { + ClientResponsePayload::TurnStart(TurnStartResponse { + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }) +} + +fn sample_turn_steer_response() -> ClientResponsePayload { + ClientResponsePayload::TurnSteer(TurnSteerResponse { + turn_id: "turn-2".to_string(), + }) +} + +#[test] +fn track_request_only_enqueues_analytics_relevant_requests() { + let (client, mut receiver) = client_with_receiver(); + + for (request_id, request) in [ + (RequestId::Integer(1), sample_turn_start_request()), + (RequestId::Integer(2), sample_turn_steer_request()), + ] { + client.track_request(/*connection_id*/ 7, request_id, &request); + assert!(matches!( + receiver.try_recv(), + Ok(AnalyticsFact::ClientRequest { .. }) + )); + } + + let ignored_request = sample_thread_archive_request(); + client.track_request( + /*connection_id*/ 7, + RequestId::Integer(3), + &ignored_request, + ); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); +} + +#[test] +fn track_response_only_enqueues_analytics_relevant_responses() { + let (client, mut receiver) = client_with_receiver(); + + for (request_id, response) in [ + (RequestId::Integer(1), sample_thread_start_response()), + (RequestId::Integer(2), sample_thread_resume_response()), + (RequestId::Integer(3), sample_thread_fork_response()), + (RequestId::Integer(4), sample_turn_start_response()), + (RequestId::Integer(5), sample_turn_steer_response()), + ] { + client.track_response(/*connection_id*/ 7, request_id, response); + assert!(matches!( + receiver.try_recv(), + Ok(AnalyticsFact::ClientResponse { .. }) + )); + } + + client.track_response( + /*connection_id*/ 7, + RequestId::Integer(6), + ClientResponsePayload::ThreadArchive(ThreadArchiveResponse {}), + ); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 86beb0b88f..424dd523b2 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -2,7 +2,7 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexRuntimeMetadata; use crate::events::GuardianReviewEventParams; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; @@ -281,7 +281,8 @@ pub(crate) enum AnalyticsFact { }, ClientResponse { connection_id: u64, - response: Box, + request_id: RequestId, + response: Box, }, ErrorResponse { connection_id: u64, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 33e89df011..43da35c47c 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -181,9 +181,12 @@ impl AnalyticsReducer { } AnalyticsFact::ClientResponse { connection_id, + request_id, response, } => { - self.ingest_response(connection_id, *response, out); + if let Some(response) = response.into_client_response(request_id) { + self.ingest_response(connection_id, response, out); + } } AnalyticsFact::ErrorResponse { connection_id, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 8f44e83830..06eaf9c8d3 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1415,6 +1415,18 @@ ], "type": "object" }, + "HooksListParams": { + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "ImageDetail": { "enum": [ "auto", @@ -1586,6 +1598,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" @@ -2024,6 +2039,31 @@ } ] }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -2035,6 +2075,40 @@ ], "type": "object" }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -4857,6 +4931,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/list" + ], + "title": "Hooks/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/listRequest", + "type": "object" + }, { "properties": { "id": { 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 35c9dc86be..ea9c050fce 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 @@ -642,6 +642,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "hooks/list" + ], + "title": "Hooks/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/HooksListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -5389,6 +5413,59 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/v2/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -9568,6 +9645,21 @@ "title": "HookCompletedNotification", "type": "object" }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, "HookEventName": { "enum": [ "preToolUse", @@ -9594,6 +9686,64 @@ ], "type": "string" }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/v2/HookHandlerType" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/v2/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "displayOrder", + "eventName", + "handlerType", + "source", + "sourcePath", + "timeoutSec" + ], + "type": "object" + }, "HookMigration": { "properties": { "name": { @@ -9779,6 +9929,68 @@ "title": "HookStartedNotification", "type": "object" }, + "HooksListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/v2/HookErrorInfo" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/definitions/v2/HookMetadata" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "hooks", + "warnings" + ], + "type": "object" + }, + "HooksListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" + }, + "HooksListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/HooksListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" + }, "ImageDetail": { "enum": [ "auto", @@ -10075,6 +10287,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" @@ -11526,6 +11741,31 @@ } ] }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -11537,6 +11777,40 @@ ], "type": "object" }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/v2/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -14718,7 +14992,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ @@ -16229,7 +16503,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ @@ -16533,7 +16807,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ 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 03ac3475e1..3cb4e970b8 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 @@ -130,6 +130,59 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -1348,6 +1401,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/list" + ], + "title": "Hooks/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -6178,6 +6255,21 @@ "title": "HookCompletedNotification", "type": "object" }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, "HookEventName": { "enum": [ "preToolUse", @@ -6204,6 +6296,64 @@ ], "type": "string" }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "displayOrder", + "eventName", + "handlerType", + "source", + "sourcePath", + "timeoutSec" + ], + "type": "object" + }, "HookMigration": { "properties": { "name": { @@ -6389,6 +6539,68 @@ "title": "HookStartedNotification", "type": "object" }, + "HooksListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/HookErrorInfo" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/definitions/HookMetadata" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "hooks", + "warnings" + ], + "type": "object" + }, + "HooksListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" + }, + "HooksListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/HooksListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" + }, "ImageDetail": { "enum": [ "auto", @@ -6729,6 +6941,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" @@ -8180,6 +8395,31 @@ } ] }, + "PermissionProfileModificationParams": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootPermissionProfileModificationParams", + "type": "object" + } + ] + }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -8191,6 +8431,40 @@ ], "type": "object" }, + "PermissionProfileSelectionParams": { + "oneOf": [ + { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", + "properties": { + "id": { + "type": "string" + }, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, + "type": [ + "array", + "null" + ] + }, + "type": { + "enum": [ + "profile" + ], + "title": "ProfilePermissionProfileSelectionParamsType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ProfilePermissionProfileSelectionParams", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -12604,7 +12878,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ @@ -14115,7 +14389,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ @@ -14419,7 +14693,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json new file mode 100644 index 0000000000..858d415f4f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json new file mode 100644 index 0000000000..c345a35d0f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -0,0 +1,160 @@ +{ + "$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" + }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "HookHandlerType": { + "enum": [ + "command", + "prompt", + "agent" + ], + "type": "string" + }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "displayOrder", + "eventName", + "handlerType", + "source", + "sourcePath", + "timeoutSec" + ], + "type": "object" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, + "HooksListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/HookErrorInfo" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/definitions/HookMetadata" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "hooks", + "warnings" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/HooksListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json index a933b71a83..ab7b852c91 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json @@ -23,6 +23,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index eb8f3bdb4f..ed81297d5f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -64,26 +64,19 @@ } ] }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { + "PermissionProfileModificationParams": { "oneOf": [ { + "description": "Additional concrete directory that should be writable.", "properties": { "path": { "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "path" + "additionalWritableRoot" ], - "title": "PathFileSystemPathType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, @@ -91,304 +84,45 @@ "path", "type" ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" } ] }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { + "PermissionProfileSelectionParams": { "oneOf": [ { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], + "id": { "type": "string" }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { + "modifications": { "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "$ref": "#/definitions/PermissionProfileModificationParams" }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, "type": [ - "integer", + "array", "null" ] }, "type": { "enum": [ - "restricted" + "profile" ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ - "entries", + "id", "type" ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "SandboxMode": { "enum": [ "read-only", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index b5d6b139b2..653c5f2387 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -5,6 +5,59 @@ "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" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -2501,7 +2554,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ 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 425fc52a48..94ecf2c26f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -138,202 +138,6 @@ } ] }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FunctionCallOutputBody": { "anyOf": [ { @@ -494,135 +298,65 @@ } ] }, - "PermissionProfile": { + "PermissionProfileModificationParams": { "oneOf": [ { - "description": "Codex owns sandbox construction for this profile.", + "description": "Additional concrete directory that should be writable.", "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" + "path": { + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "managed" + "additionalWritableRoot" ], - "title": "ManagedPermissionProfileType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, "required": [ - "fileSystem", - "network", + "path", "type" ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" } ] }, - "PermissionProfileFileSystemPermissions": { + "PermissionProfileSelectionParams": { "oneOf": [ { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" + "id": { + "type": "string" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, "type": [ - "integer", + "array", "null" ] }, "type": { "enum": [ - "restricted" + "profile" ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ - "entries", + "id", "type" ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 7135a53174..27cf47f2fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -5,6 +5,59 @@ "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" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -2501,7 +2554,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index d5e76f05e3..d5f0e9bfcc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,26 +90,19 @@ ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { + "PermissionProfileModificationParams": { "oneOf": [ { + "description": "Additional concrete directory that should be writable.", "properties": { "path": { "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "path" + "additionalWritableRoot" ], - "title": "PathFileSystemPathType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, @@ -117,304 +110,45 @@ "path", "type" ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" } ] }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { + "PermissionProfileSelectionParams": { "oneOf": [ { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], + "id": { "type": "string" }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { + "modifications": { "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "$ref": "#/definitions/PermissionProfileModificationParams" }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, "type": [ - "integer", + "array", "null" ] }, "type": { "enum": [ - "restricted" + "profile" ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ - "entries", + "id", "type" ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 5deca9f699..7d93606aa4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -5,6 +5,59 @@ "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" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + }, + "modifications": { + "default": [], + "description": "Bounded user-requested modifications applied on top of the named profile, if any.", + "items": { + "$ref": "#/definitions/ActivePermissionProfileModification" + }, + "type": "array" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ActivePermissionProfileModification": { + "oneOf": [ + { + "description": "Additional concrete directory that should be writable.", + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "additionalWritableRoot" + ], + "title": "AdditionalWritableRootActivePermissionProfileModificationType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "AdditionalWritableRootActivePermissionProfileModification", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -2501,7 +2554,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 719a1bedc8..da1320a796 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -99,202 +99,6 @@ ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -310,135 +114,65 @@ ], "type": "string" }, - "PermissionProfile": { + "PermissionProfileModificationParams": { "oneOf": [ { - "description": "Codex owns sandbox construction for this profile.", + "description": "Additional concrete directory that should be writable.", "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" + "path": { + "$ref": "#/definitions/AbsolutePathBuf" }, "type": { "enum": [ - "managed" + "additionalWritableRoot" ], - "title": "ManagedPermissionProfileType", + "title": "AdditionalWritableRootPermissionProfileModificationParamsType", "type": "string" } }, "required": [ - "fileSystem", - "network", + "path", "type" ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", + "title": "AdditionalWritableRootPermissionProfileModificationParams", "type": "object" } ] }, - "PermissionProfileFileSystemPermissions": { + "PermissionProfileSelectionParams": { "oneOf": [ { + "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" + "id": { + "type": "string" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, + "modifications": { + "items": { + "$ref": "#/definitions/PermissionProfileModificationParams" + }, "type": [ - "integer", + "array", "null" ] }, "type": { "enum": [ - "restricted" + "profile" ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "title": "ProfilePermissionProfileSelectionParamsType", "type": "string" } }, "required": [ - "entries", + "id", "type" ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "ProfilePermissionProfileSelectionParams", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index d1161cdba2..a86297e3aa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -34,6 +34,7 @@ import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; +import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; import type { MarketplaceAddParams } from "./v2/MarketplaceAddParams"; @@ -77,4 +78,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/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "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": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "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": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "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, }; +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/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "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": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "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": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "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/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts new file mode 100644 index 0000000000..cbc8c6ef0a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -0,0 +1,21 @@ +// 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 { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; + +export type ActivePermissionProfile = { +/** + * Identifier from `default_permissions` or the implicit built-in default, + * such as `:workspace` or a user-defined `[permissions.]` profile. + */ +id: string, +/** + * Parent profile identifier once permissions profiles support + * inheritance. This is currently always `null`. + */ +extends: string | null, +/** + * Bounded user-requested modifications applied on top of the named + * profile, if any. + */ +modifications: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts new file mode 100644 index 0000000000..1cbee6868a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.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 ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts new file mode 100644 index 0000000000..75c259b0c0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.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 HookErrorInfo = { path: string, message: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts new file mode 100644 index 0000000000..074d0cfb6d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -0,0 +1,9 @@ +// 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 { HookEventName } from "./HookEventName"; +import type { HookHandlerType } from "./HookHandlerType"; +import type { HookSource } from "./HookSource"; + +export type HookMetadata = { eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts new file mode 100644 index 0000000000..256b29bb46 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.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 { HookErrorInfo } from "./HookErrorInfo"; +import type { HookMetadata } from "./HookMetadata"; + +export type HooksListEntry = { cwd: string, hooks: Array, warnings: Array, errors: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts new file mode 100644 index 0000000000..db29387d29 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts @@ -0,0 +1,9 @@ +// 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 HooksListParams = { +/** + * When empty, defaults to the current session working directory. + */ +cwds?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts new file mode 100644 index 0000000000..4c2dd1a8db --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.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 { HooksListEntry } from "./HooksListEntry"; + +export type HooksListResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts index 4831a6b2de..e6f1e2ed43 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", +export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", /** * Access token (JWT) supplied by the client. * This token is used for backend API requests and email extraction. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts new file mode 100644 index 0000000000..c619edcea8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.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 PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts new file mode 100644 index 0000000000..a415bd0028 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.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 { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; + +export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index 1207002514..ddcef104e9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -16,8 +16,8 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** - * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 20f91b3e91..f7627c07ae 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -16,8 +16,8 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** - * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 87e15411a2..ce28a4a1d7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -16,8 +16,8 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ approvalsReviewer: ApprovalsReviewer, /** - * Legacy sandbox policy retained for compatibility. New clients should use - * `permissionProfile` when present as the canonical active permissions - * view. + * Legacy sandbox policy retained for compatibility. Experimental clients + * should prefer `permissionProfile` when they need exact runtime + * permissions. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | 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 38a1aaf334..34534c5c89 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -4,6 +4,8 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; +export type { ActivePermissionProfile } from "./ActivePermissionProfile"; +export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; @@ -152,9 +154,11 @@ export type { GuardianRiskLevel } from "./GuardianRiskLevel"; export type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; export type { GuardianWarningNotification } from "./GuardianWarningNotification"; export type { HookCompletedNotification } from "./HookCompletedNotification"; +export type { HookErrorInfo } from "./HookErrorInfo"; export type { HookEventName } from "./HookEventName"; export type { HookExecutionMode } from "./HookExecutionMode"; export type { HookHandlerType } from "./HookHandlerType"; +export type { HookMetadata } from "./HookMetadata"; export type { HookMigration } from "./HookMigration"; export type { HookOutputEntry } from "./HookOutputEntry"; export type { HookOutputEntryKind } from "./HookOutputEntryKind"; @@ -164,6 +168,9 @@ export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; +export type { HooksListEntry } from "./HooksListEntry"; +export type { HooksListParams } from "./HooksListParams"; +export type { HooksListResponse } from "./HooksListResponse"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification"; @@ -256,7 +263,9 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; +export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; +export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e89af03d85..cdea769f02 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -157,6 +157,7 @@ macro_rules! client_request_definitions { params: $(#[$params_meta:meta])* $params:ty, $(inspect_params: $inspect_params:tt,)? serialization: $serialization:ident $( ( $($serialization_args:tt)* ) )?, + $(manual_payload_conversion: $manual_payload_conversion:ident,)? response: $response:ty, } ),* $(,)? @@ -243,8 +244,100 @@ macro_rules! client_request_definitions { }) .unwrap_or_else(|| "".to_string()) } + + pub fn into_jsonrpc_parts( + self, + ) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> { + match self { + $( + Self::$variant { request_id, response } => { + serde_json::to_value(response).map(|result| (request_id, result)) + } + )* + } + } } + #[derive(Debug, Clone)] + #[allow(clippy::large_enum_variant)] + pub enum ClientResponsePayload { + $( $variant($response), )* + InterruptConversation(v1::InterruptConversationResponse), + } + + impl ClientResponsePayload { + pub fn into_jsonrpc_parts_and_payload( + self, + request_id: RequestId, + ) -> std::result::Result< + (RequestId, crate::Result, Option), + serde_json::Error, + > { + match self { + $( + Self::$variant(response) => { + let result = serde_json::to_value(&response)?; + Ok((request_id, result, Some(Self::$variant(response)))) + } + )* + Self::InterruptConversation(response) => { + serde_json::to_value(response).map(|result| (request_id, result, None)) + } + } + } + + pub fn into_client_response(self, request_id: RequestId) -> Option { + match self { + $( + Self::$variant(response) => { + Some(ClientResponse::$variant { + request_id, + response, + }) + } + )* + Self::InterruptConversation(_) => None, + } + } + + pub fn into_jsonrpc_parts( + self, + request_id: RequestId, + ) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> { + self.to_jsonrpc_parts(request_id) + } + + pub fn to_jsonrpc_parts( + &self, + request_id: RequestId, + ) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> { + match self { + $( + Self::$variant(response) => { + serde_json::to_value(response).map(|result| (request_id, result)) + } + )* + Self::InterruptConversation(response) => { + serde_json::to_value(response).map(|result| (request_id, result)) + } + } + } + } + + impl From for ClientResponsePayload { + fn from(response: v1::InterruptConversationResponse) -> Self { + Self::InterruptConversation(response) + } + } + + $( + client_response_payload_from_impl!( + $variant, + $response + $(, $manual_payload_conversion)? + ); + )* + impl crate::experimental_api::ExperimentalApi for ClientRequest { fn experimental_reason(&self) -> Option<&'static str> { match self { @@ -317,6 +410,17 @@ macro_rules! client_request_definitions { }; } +macro_rules! client_response_payload_from_impl { + ($variant:ident, $response:ty) => { + impl From<$response> for ClientResponsePayload { + fn from(response: $response) -> Self { + Self::$variant(response) + } + } + }; + ($variant:ident, $response:ty, manual) => {}; +} + client_request_definitions! { Initialize { params: v1::InitializeParams, @@ -477,6 +581,11 @@ client_request_definitions! { serialization: global("config"), response: v2::SkillsListResponse, }, + HooksList => "hooks/list" { + params: v2::HooksListParams, + serialization: global("config"), + response: v2::HooksListResponse, + }, MarketplaceAdd => "marketplace/add" { params: v2::MarketplaceAddParams, serialization: global("config"), @@ -789,11 +898,13 @@ client_request_definitions! { ConfigValueWrite => "config/value/write" { params: v2::ConfigValueWriteParams, serialization: global("config"), + manual_payload_conversion: manual, response: v2::ConfigWriteResponse, }, ConfigBatchWrite => "config/batchWrite" { params: v2::ConfigBatchWriteParams, serialization: global("config"), + manual_payload_conversion: manual, response: v2::ConfigWriteResponse, }, @@ -2020,12 +2131,8 @@ mod tests { approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, - permission_profile: Some( - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &codex_protocol::protocol::SandboxPolicy::DangerFullAccess, - ) - .into(), - ), + permission_profile: None, + active_permission_profile: None, reasoning_effort: None, }, }; @@ -2068,9 +2175,8 @@ mod tests { "sandbox": { "type": "dangerFullAccess" }, - "permissionProfile": { - "type": "disabled" - }, + "permissionProfile": null, + "activePermissionProfile": null, "reasoningEffort": null } }), @@ -2121,7 +2227,9 @@ mod tests { fn serialize_account_login_chatgpt() -> Result<()> { let request = ClientRequest::LoginAccount { request_id: RequestId::Integer(3), - params: v2::LoginAccountParams::Chatgpt, + params: v2::LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + }, }; assert_eq!( json!({ @@ -2136,6 +2244,28 @@ mod tests { Ok(()) } + #[test] + fn serialize_account_login_chatgpt_streamlined() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(3), + params: v2::LoginAccountParams::Chatgpt { + codex_streamlined_login: true, + }, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 3, + "params": { + "type": "chatgpt", + "codexStreamlinedLogin": true + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_account_login_chatgpt_device_code() -> Result<()> { let request = ClientRequest::LoginAccount { @@ -2766,3 +2896,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "common_tests.rs"] +mod common_tests; diff --git a/codex-rs/app-server-protocol/src/protocol/common_tests.rs b/codex-rs/app-server-protocol/src/protocol/common_tests.rs new file mode 100644 index 0000000000..83e5d37117 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/common_tests.rs @@ -0,0 +1,44 @@ +use super::*; +use anyhow::Result; +use codex_protocol::protocol::TurnAbortReason; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn client_response_payload_returns_jsonrpc_parts_and_client_response() -> Result<()> { + let (request_id, result, payload) = + ClientResponsePayload::ThreadArchive(v2::ThreadArchiveResponse {}) + .into_jsonrpc_parts_and_payload(RequestId::Integer(7))?; + + assert_eq!(request_id, RequestId::Integer(7)); + assert_eq!(result, json!({})); + + let Some(ClientResponse::ThreadArchive { + request_id, + response: _, + }) = payload.and_then(|payload| payload.into_client_response(RequestId::Integer(7))) + else { + panic!("expected thread/archive client response"); + }; + assert_eq!(request_id, RequestId::Integer(7)); + Ok(()) +} + +#[test] +fn interrupt_conversation_payload_stays_jsonrpc_only() -> Result<()> { + let (request_id, result, payload) = + ClientResponsePayload::InterruptConversation(v1::InterruptConversationResponse { + abort_reason: TurnAbortReason::Interrupted, + }) + .into_jsonrpc_parts_and_payload(RequestId::Integer(8))?; + + assert_eq!(request_id, RequestId::Integer(8)); + assert_eq!( + result, + json!({ + "abortReason": "interrupted", + }) + ); + assert!(payload.is_none()); + Ok(()) +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4669ec6cc9..17aafe1ae4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -38,6 +38,8 @@ use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; use codex_protocol::mcp::Tool as McpTool; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -1703,6 +1705,109 @@ impl From for CorePermissionProfile { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ActivePermissionProfile { + /// Identifier from `default_permissions` or the implicit built-in default, + /// such as `:workspace` or a user-defined `[permissions.]` profile. + pub id: String, + /// Parent profile identifier once permissions profiles support + /// inheritance. This is currently always `null`. + #[serde(default)] + pub extends: Option, + /// Bounded user-requested modifications applied on top of the named + /// profile, if any. + #[serde(default)] + pub modifications: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ActivePermissionProfileModification { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +impl From for ActivePermissionProfileModification { + fn from(value: CoreActivePermissionProfileModification) -> Self { + match value { + CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for CoreActivePermissionProfileModification { + fn from(value: ActivePermissionProfileModification) -> Self { + match value { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for ActivePermissionProfile { + fn from(value: CoreActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(ActivePermissionProfileModification::from) + .collect(), + } + } +} + +impl From for CoreActivePermissionProfile { + fn from(value: ActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(CoreActivePermissionProfileModification::from) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileSelectionParams { + /// Select a named built-in or user-defined profile and optionally apply + /// bounded modifications that Codex knows how to validate. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Profile { + id: String, + #[ts(optional = nullable)] + modifications: Option>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileModificationParams { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2174,9 +2279,12 @@ pub enum LoginAccountParams { #[ts(rename = "apiKey")] api_key: String, }, - #[serde(rename = "chatgpt")] - #[ts(rename = "chatgpt")] - Chatgpt, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + codex_streamlined_login: bool, + }, #[serde(rename = "chatgptDeviceCode")] #[ts(rename = "chatgptDeviceCode")] ChatgptDeviceCode, @@ -3459,11 +3567,12 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Full permissions override for this thread. Cannot be combined with - /// `sandbox`. - #[experimental("thread/start.permissionProfile")] + /// Named profile selection for this thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported turn/thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/start.permissions")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3540,14 +3649,20 @@ pub struct ThreadStartResponse { pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. New clients should use - /// `permissionProfile` when present as the canonical active permissions - /// view. + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. #[experimental("thread/start.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/start.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -3605,11 +3720,12 @@ pub struct ThreadResumeParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Full permissions override for the resumed thread. Cannot be combined - /// with `sandbox`. - #[experimental("thread/resume.permissionProfile")] + /// Named profile selection for the resumed thread. Cannot be combined + /// with `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/resume.permissions")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3646,14 +3762,20 @@ pub struct ThreadResumeResponse { pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. New clients should use - /// `permissionProfile` when present as the canonical active permissions - /// view. + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. #[experimental("thread/resume.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/resume.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -3702,11 +3824,12 @@ pub struct ThreadForkParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Full permissions override for the forked thread. Cannot be combined - /// with `sandbox`. - #[experimental("thread/fork.permissionProfile")] + /// Named profile selection for the forked thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/fork.permissions")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3743,14 +3866,20 @@ pub struct ThreadForkResponse { pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. New clients should use - /// `permissionProfile` when present as the canonical active permissions - /// view. + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread. + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. #[experimental("thread/fork.permissionProfile")] #[serde(default)] pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/fork.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, pub reasoning_effort: Option, } @@ -4354,6 +4483,22 @@ pub struct SkillsListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListResponse { + pub data: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4557,6 +4702,40 @@ pub struct SkillsListEntry { pub errors: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListEntry { + pub cwd: PathBuf, + pub hooks: Vec, + pub warnings: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMetadata { + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub matcher: Option, + pub command: Option, + pub timeout_sec: u64, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source: HookSource, + pub plugin_id: Option, + pub display_order: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookErrorInfo { + pub path: PathBuf, + pub message: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -5285,11 +5464,13 @@ pub struct TurnStartParams { /// Override the sandbox policy for this turn and subsequent turns. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Override the full permissions profile for this turn and subsequent - /// turns. Cannot be combined with `sandboxPolicy`. - #[experimental("turn/start.permissionProfile")] + /// Select a named permissions profile for this turn and subsequent turns. + /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` + /// for supported turn adjustments instead of replacing the full + /// permissions profile. + #[experimental("turn/start.permissions")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permissions: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, @@ -10737,6 +10918,9 @@ mod tests { assert_eq!(start.permission_profile, None); assert_eq!(resume.permission_profile, None); assert_eq!(fork.permission_profile, None); + assert_eq!(start.active_permission_profile, None); + assert_eq!(resume.active_permission_profile, None); + assert_eq!(fork.active_permission_profile, None); } #[test] @@ -10764,7 +10948,7 @@ mod tests { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, - permission_profile: None, + permissions: None, model: None, service_tier: None, effort: None, diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 2a3cea273b..edea431c61 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1607,7 +1607,9 @@ impl CodexClient { let request_id = self.request_id(); let request = ClientRequest::LoginAccount { request_id: request_id.clone(), - params: codex_app_server_protocol::LoginAccountParams::Chatgpt, + params: codex_app_server_protocol::LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + }, }; self.send_request(request, request_id, "account/login/start") diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index f2beb6a124..9771466cd3 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -42,6 +42,7 @@ codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } codex-git-utils = { workspace = true } +codex-hooks = { workspace = true } codex-otel = { workspace = true } codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d96b0e514a..ce86a4df09 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -88,7 +88,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. -- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or `permissionProfile`, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. +- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or experimental `permissions` profile selection, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. - Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. @@ -142,9 +142,10 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Pass `excludeTurns: true` when the client plans to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -166,7 +167,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer `permissionProfile` for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissionProfile`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -194,8 +195,9 @@ Example with notification opt-out: - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `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`. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. -- `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. +- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. 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`). +- `hooks/list` — list discovered hooks for one or more `cwd` values. - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. @@ -219,8 +221,8 @@ Example with notification opt-out: - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `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. - `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), and plugin migration items may additionally include structured `details` grouping plugin ids under each detected marketplace name. -- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote imports finish). +- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes plugin or session imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. @@ -237,8 +239,9 @@ Start a fresh thread when you need a new Codex conversation. "cwd": "/Users/me/project", "approvalPolicy": "never", "sandbox": "workspaceWrite", - // Prefer "permissionProfile" for full permission overrides. Do not send - // both "sandbox" and "permissionProfile". + // Prefer experimental profile selection: + // "permissions": { "type": "profile", "id": ":workspace" } + // Do not send both "sandbox" and "permissions". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) "sessionStartSource": "startup", // optional: "startup" (default) or "clear" @@ -635,8 +638,9 @@ You can optionally specify config overrides on the new turn. If specified, these "writableRoots": ["/Users/me/project"], "networkAccess": true }, - // Prefer "permissionProfile" for full permission overrides. Do not send - // both "sandboxPolicy" and "permissionProfile". + // Prefer experimental profile selection: + // "permissions": { "type": "profile", "id": ":workspace" } + // Do not send both "sandboxPolicy" and "permissions". "model": "gpt-5.1-codex", "effort": "medium", "summary": "concise", @@ -1452,6 +1456,43 @@ To enable or disable a skill by name: } ``` +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. + +```json +{ + "method": "hooks/list", + "id": 28, + "params": { + "cwds": ["/Users/me/project"] + } +} +``` + +```json +{ + "id": 28, + "result": { + "data": [{ + "cwd": "/Users/me/project", + "hooks": [{ + "eventName": "pre_tool_use", + "handlerType": "command", + "matcher": "Bash", + "command": "python3 /Users/me/hook.py", + "timeoutSec": 5, + "statusMessage": "running hook", + "sourcePath": "/Users/me/.codex/config.toml", + "source": "user", + "pluginId": null, + "displayOrder": 0 + }], + "warnings": [], + "errors": [] + }] + } +} +``` + ## Apps Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ed13b346aa..00f3635f3d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1,4 +1,3 @@ -use crate::codex_message_processor::ApiVersion; use crate::codex_message_processor::read_rollout_items_from_rollout; use crate::codex_message_processor::read_summary_from_rollout; use crate::codex_message_processor::summary_to_thread; @@ -16,8 +15,6 @@ use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; -use codex_app_server_protocol::ApplyPatchApprovalParams; -use codex_app_server_protocol::ApplyPatchApprovalResponse; use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo; use codex_app_server_protocol::CollabAgentState as V2CollabAgentStatus; use codex_app_server_protocol::CollabAgentTool; @@ -29,14 +26,11 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; -use codex_app_server_protocol::ContextCompactedNotification; use codex_app_server_protocol::DeprecationNoticeNotification; use codex_app_server_protocol::DynamicToolCallOutputContentItem; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::DynamicToolCallStatus; use codex_app_server_protocol::ErrorNotification; -use codex_app_server_protocol::ExecCommandApprovalParams; -use codex_app_server_protocol::ExecCommandApprovalResponse; use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment; use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeOutputDeltaNotification; @@ -48,7 +42,6 @@ use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionPr use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::HookCompletedNotification; use codex_app_server_protocol::HookStartedNotification; -use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::McpServerElicitationAction; @@ -120,7 +113,6 @@ use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; -use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -179,7 +171,6 @@ pub(crate) async fn apply_bespoke_event_handling( thread_state: Arc>, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, - api_version: ApiVersion, fallback_model_provider: String, codex_home: &Path, ) { @@ -194,36 +185,34 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_started(&conversation_id.to_string()) .await; - if let ApiVersion::V2 = api_version { - let turn = { - let state = thread_state.lock().await; - state.active_turn_snapshot().unwrap_or_else(|| Turn { - id: payload.turn_id.clone(), - items: Vec::new(), - error: None, - status: TurnStatus::InProgress, - started_at: payload.started_at, - completed_at: None, - duration_ms: None, - }) - }; - let notification = TurnStartedNotification { - thread_id: conversation_id.to_string(), - turn, - }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client - .track_notification(ServerNotification::TurnStarted(notification.clone())); - } - outgoing - .send_server_notification(ServerNotification::TurnStarted(notification)) - .await; + let turn = { + let state = thread_state.lock().await; + state.active_turn_snapshot().unwrap_or_else(|| Turn { + id: payload.turn_id.clone(), + items: Vec::new(), + error: None, + status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, + }) + }; + let notification = TurnStartedNotification { + thread_id: conversation_id.to_string(), + turn, + }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::TurnStarted(notification.clone())); } + outgoing + .send_server_notification(ServerNotification::TurnStarted(notification)) + .await; } EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; - respond_to_pending_interrupts(&thread_state, &outgoing, /*abort_reason*/ None).await; + respond_to_pending_interrupts(&thread_state, &outgoing).await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) @@ -239,435 +228,377 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::SkillsUpdateAvailable => { - if let ApiVersion::V2 = api_version { - outgoing - .send_server_notification(ServerNotification::SkillsChanged( - SkillsChangedNotification {}, - )) - .await; - } + outgoing + .send_server_notification(ServerNotification::SkillsChanged( + SkillsChangedNotification {}, + )) + .await; } EventMsg::McpStartupUpdate(update) => { - if let ApiVersion::V2 = api_version { - let (status, error) = match update.status { - codex_protocol::protocol::McpStartupStatus::Starting => { - (McpServerStartupState::Starting, None) - } - codex_protocol::protocol::McpStartupStatus::Ready => { - (McpServerStartupState::Ready, None) - } - codex_protocol::protocol::McpStartupStatus::Failed { error } => { - (McpServerStartupState::Failed, Some(error)) - } - codex_protocol::protocol::McpStartupStatus::Cancelled => { - (McpServerStartupState::Cancelled, None) - } - }; - let notification = McpServerStatusUpdatedNotification { - name: update.server, - status, - error, - }; - outgoing - .send_server_notification(ServerNotification::McpServerStatusUpdated( - notification, - )) - .await; - } + let (status, error) = match update.status { + codex_protocol::protocol::McpStartupStatus::Starting => { + (McpServerStartupState::Starting, None) + } + codex_protocol::protocol::McpStartupStatus::Ready => { + (McpServerStartupState::Ready, None) + } + codex_protocol::protocol::McpStartupStatus::Failed { error } => { + (McpServerStartupState::Failed, Some(error)) + } + codex_protocol::protocol::McpStartupStatus::Cancelled => { + (McpServerStartupState::Cancelled, None) + } + }; + let notification = McpServerStatusUpdatedNotification { + name: update.server, + status, + error, + }; + outgoing + .send_server_notification(ServerNotification::McpServerStatusUpdated(notification)) + .await; } EventMsg::Warning(warning_event) => { - if let ApiVersion::V2 = api_version { - let notification = WarningNotification { - thread_id: Some(conversation_id.to_string()), - message: warning_event.message, - }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client - .track_notification(ServerNotification::Warning(notification.clone())); - } - outgoing - .send_server_notification(ServerNotification::Warning(notification)) - .await; + let notification = WarningNotification { + thread_id: Some(conversation_id.to_string()), + message: warning_event.message, + }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::Warning(notification.clone())); } + outgoing + .send_server_notification(ServerNotification::Warning(notification)) + .await; } EventMsg::GuardianWarning(warning_event) => { - if let ApiVersion::V2 = api_version { - let notification = GuardianWarningNotification { - thread_id: conversation_id.to_string(), - message: warning_event.message, - }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client.track_notification( - ServerNotification::GuardianWarning(notification.clone()), - ); - } - outgoing - .send_server_notification(ServerNotification::GuardianWarning(notification)) - .await; + let notification = GuardianWarningNotification { + thread_id: conversation_id.to_string(), + message: warning_event.message, + }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client + .track_notification(ServerNotification::GuardianWarning(notification.clone())); } + outgoing + .send_server_notification(ServerNotification::GuardianWarning(notification)) + .await; } EventMsg::GuardianAssessment(assessment) => { - if let ApiVersion::V2 = api_version { - let pending_command_execution = match build_item_from_guardian_event( - &assessment, - CommandExecutionStatus::InProgress, - ) { - Some(ThreadItem::CommandExecution { - id, + let pending_command_execution = match build_item_from_guardian_event( + &assessment, + CommandExecutionStatus::InProgress, + ) { + Some(ThreadItem::CommandExecution { + id, + command, + cwd, + command_actions, + .. + }) => Some(( + id, + CommandExecutionCompletionItem { command, cwd, command_actions, - .. - }) => Some(( - id, - CommandExecutionCompletionItem { - command, - cwd, - command_actions, - }, - )), - Some(_) | None => None, - }; - let assessment_turn_id = if assessment.turn_id.is_empty() { - event_turn_id.clone() - } else { - assessment.turn_id.clone() - }; - if assessment.status - == codex_protocol::protocol::GuardianAssessmentStatus::InProgress - && let Some((target_item_id, completion_item)) = - pending_command_execution.as_ref() - { - start_command_execution_item( - &conversation_id, - assessment_turn_id.clone(), - target_item_id.clone(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, - &outgoing, - &thread_state, - ) - .await; - } - let notification = guardian_auto_approval_review_notification( + }, + )), + Some(_) | None => None, + }; + let assessment_turn_id = if assessment.turn_id.is_empty() { + event_turn_id.clone() + } else { + assessment.turn_id.clone() + }; + if assessment.status == codex_protocol::protocol::GuardianAssessmentStatus::InProgress + && let Some((target_item_id, completion_item)) = pending_command_execution.as_ref() + { + start_command_execution_item( &conversation_id, - &event_turn_id, - &assessment, - ); - outgoing.send_server_notification(notification).await; - let completion_status = match assessment.status { - codex_protocol::protocol::GuardianAssessmentStatus::Denied - | codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { - Some(CommandExecutionStatus::Declined) - } - codex_protocol::protocol::GuardianAssessmentStatus::TimedOut => { - Some(CommandExecutionStatus::Failed) - } - codex_protocol::protocol::GuardianAssessmentStatus::InProgress - | codex_protocol::protocol::GuardianAssessmentStatus::Approved => None, - }; - if let Some(completion_status) = completion_status - && let Some((target_item_id, completion_item)) = pending_command_execution - { - complete_command_execution_item( - &conversation_id, - assessment_turn_id, - target_item_id, - completion_item.command, - completion_item.cwd, - /*process_id*/ None, - CommandExecutionSource::Agent, - completion_item.command_actions, - completion_status, - &outgoing, - &thread_state, - ) - .await; + assessment_turn_id.clone(), + target_item_id.clone(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + } + let notification = guardian_auto_approval_review_notification( + &conversation_id, + &event_turn_id, + &assessment, + ); + outgoing.send_server_notification(notification).await; + let completion_status = match assessment.status { + codex_protocol::protocol::GuardianAssessmentStatus::Denied + | codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { + Some(CommandExecutionStatus::Declined) } + codex_protocol::protocol::GuardianAssessmentStatus::TimedOut => { + Some(CommandExecutionStatus::Failed) + } + codex_protocol::protocol::GuardianAssessmentStatus::InProgress + | codex_protocol::protocol::GuardianAssessmentStatus::Approved => None, + }; + if let Some(completion_status) = completion_status + && let Some((target_item_id, completion_item)) = pending_command_execution + { + complete_command_execution_item( + &conversation_id, + assessment_turn_id, + target_item_id, + completion_item.command, + completion_item.cwd, + /*process_id*/ None, + CommandExecutionSource::Agent, + completion_item.command_actions, + completion_status, + &outgoing, + &thread_state, + ) + .await; } } EventMsg::ModelReroute(event) => { - if let ApiVersion::V2 = api_version { - let notification = ModelReroutedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - from_model: event.from_model, - to_model: event.to_model, - reason: event.reason.into(), - }; - outgoing - .send_server_notification(ServerNotification::ModelRerouted(notification)) - .await; - } + let notification = ModelReroutedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + from_model: event.from_model, + to_model: event.to_model, + reason: event.reason.into(), + }; + outgoing + .send_server_notification(ServerNotification::ModelRerouted(notification)) + .await; } EventMsg::ModelVerification(event) => { - if let ApiVersion::V2 = api_version { - let notification = ModelVerificationNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - verifications: event.verifications.into_iter().map(Into::into).collect(), - }; - outgoing - .send_server_notification(ServerNotification::ModelVerification(notification)) - .await; - } + let notification = ModelVerificationNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + verifications: event.verifications.into_iter().map(Into::into).collect(), + }; + outgoing + .send_server_notification(ServerNotification::ModelVerification(notification)) + .await; } EventMsg::RealtimeConversationStarted(event) => { - if let ApiVersion::V2 = api_version { - let notification = ThreadRealtimeStartedNotification { - thread_id: conversation_id.to_string(), - session_id: event.session_id, - version: event.version, - }; - outgoing - .send_server_notification(ServerNotification::ThreadRealtimeStarted( - notification, - )) - .await; - } + let notification = ThreadRealtimeStartedNotification { + thread_id: conversation_id.to_string(), + session_id: event.session_id, + version: event.version, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeStarted(notification)) + .await; } EventMsg::RealtimeConversationSdp(event) => { - if let ApiVersion::V2 = api_version { - let notification = ThreadRealtimeSdpNotification { + let notification = ThreadRealtimeSdpNotification { + thread_id: conversation_id.to_string(), + sdp: event.sdp, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeSdp(notification)) + .await; + } + EventMsg::RealtimeConversationRealtime(event) => match event.payload { + RealtimeEvent::SessionUpdated { .. } => {} + RealtimeEvent::InputAudioSpeechStarted(event) => { + let notification = ThreadRealtimeItemAddedNotification { thread_id: conversation_id.to_string(), - sdp: event.sdp, + item: serde_json::json!({ + "type": "input_audio_buffer.speech_started", + "item_id": event.item_id, + }), }; outgoing - .send_server_notification(ServerNotification::ThreadRealtimeSdp(notification)) - .await; - } - } - EventMsg::RealtimeConversationRealtime(event) => { - if let ApiVersion::V2 = api_version { - match event.payload { - RealtimeEvent::SessionUpdated { .. } => {} - RealtimeEvent::InputAudioSpeechStarted(event) => { - let notification = ThreadRealtimeItemAddedNotification { - thread_id: conversation_id.to_string(), - item: serde_json::json!({ - "type": "input_audio_buffer.speech_started", - "item_id": event.item_id, - }), - }; - outgoing - .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( - notification, - )) - .await; - } - RealtimeEvent::InputTranscriptDelta(event) => { - let notification = ThreadRealtimeTranscriptDeltaNotification { - thread_id: conversation_id.to_string(), - role: "user".to_string(), - delta: event.delta, - }; - outgoing - .send_server_notification( - ServerNotification::ThreadRealtimeTranscriptDelta(notification), - ) - .await; - } - RealtimeEvent::InputTranscriptDone(event) => { - let notification = ThreadRealtimeTranscriptDoneNotification { - thread_id: conversation_id.to_string(), - role: "user".to_string(), - text: event.text, - }; - outgoing - .send_server_notification( - ServerNotification::ThreadRealtimeTranscriptDone(notification), - ) - .await; - } - RealtimeEvent::OutputTranscriptDelta(event) => { - let notification = ThreadRealtimeTranscriptDeltaNotification { - thread_id: conversation_id.to_string(), - role: "assistant".to_string(), - delta: event.delta, - }; - outgoing - .send_server_notification( - ServerNotification::ThreadRealtimeTranscriptDelta(notification), - ) - .await; - } - RealtimeEvent::OutputTranscriptDone(event) => { - let notification = ThreadRealtimeTranscriptDoneNotification { - thread_id: conversation_id.to_string(), - role: "assistant".to_string(), - text: event.text, - }; - outgoing - .send_server_notification( - ServerNotification::ThreadRealtimeTranscriptDone(notification), - ) - .await; - } - RealtimeEvent::AudioOut(audio) => { - let notification = ThreadRealtimeOutputAudioDeltaNotification { - thread_id: conversation_id.to_string(), - audio: audio.into(), - }; - outgoing - .send_server_notification( - ServerNotification::ThreadRealtimeOutputAudioDelta(notification), - ) - .await; - } - RealtimeEvent::ResponseCreated(_) => {} - RealtimeEvent::ResponseCancelled(event) => { - let notification = ThreadRealtimeItemAddedNotification { - thread_id: conversation_id.to_string(), - item: serde_json::json!({ - "type": "response.cancelled", - "response_id": event.response_id, - }), - }; - outgoing - .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( - notification, - )) - .await; - } - RealtimeEvent::ResponseDone(_) => {} - RealtimeEvent::ConversationItemAdded(item) => { - let notification = ThreadRealtimeItemAddedNotification { - thread_id: conversation_id.to_string(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( - notification, - )) - .await; - } - RealtimeEvent::ConversationItemDone { .. } - | RealtimeEvent::NoopRequested(_) => {} - RealtimeEvent::HandoffRequested(handoff) => { - let notification = ThreadRealtimeItemAddedNotification { - thread_id: conversation_id.to_string(), - item: serde_json::json!({ - "type": "handoff_request", - "handoff_id": handoff.handoff_id, - "item_id": handoff.item_id, - "input_transcript": handoff.input_transcript, - "active_transcript": handoff.active_transcript, - }), - }; - outgoing - .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( - notification, - )) - .await; - } - RealtimeEvent::Error(message) => { - let notification = ThreadRealtimeErrorNotification { - thread_id: conversation_id.to_string(), - message, - }; - outgoing - .send_server_notification(ServerNotification::ThreadRealtimeError( - notification, - )) - .await; - } - } - } - } - EventMsg::RealtimeConversationClosed(event) => { - if let ApiVersion::V2 = api_version { - let notification = ThreadRealtimeClosedNotification { - thread_id: conversation_id.to_string(), - reason: event.reason, - }; - outgoing - .send_server_notification(ServerNotification::ThreadRealtimeClosed( + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( notification, )) .await; } + RealtimeEvent::InputTranscriptDelta(event) => { + let notification = ThreadRealtimeTranscriptDeltaNotification { + thread_id: conversation_id.to_string(), + role: "user".to_string(), + delta: event.delta, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeTranscriptDelta( + notification, + )) + .await; + } + RealtimeEvent::InputTranscriptDone(event) => { + let notification = ThreadRealtimeTranscriptDoneNotification { + thread_id: conversation_id.to_string(), + role: "user".to_string(), + text: event.text, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeTranscriptDone( + notification, + )) + .await; + } + RealtimeEvent::OutputTranscriptDelta(event) => { + let notification = ThreadRealtimeTranscriptDeltaNotification { + thread_id: conversation_id.to_string(), + role: "assistant".to_string(), + delta: event.delta, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeTranscriptDelta( + notification, + )) + .await; + } + RealtimeEvent::OutputTranscriptDone(event) => { + let notification = ThreadRealtimeTranscriptDoneNotification { + thread_id: conversation_id.to_string(), + role: "assistant".to_string(), + text: event.text, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeTranscriptDone( + notification, + )) + .await; + } + RealtimeEvent::AudioOut(audio) => { + let notification = ThreadRealtimeOutputAudioDeltaNotification { + thread_id: conversation_id.to_string(), + audio: audio.into(), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeOutputAudioDelta( + notification, + )) + .await; + } + RealtimeEvent::ResponseCreated(_) => {} + RealtimeEvent::ResponseCancelled(event) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item: serde_json::json!({ + "type": "response.cancelled", + "response_id": event.response_id, + }), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } + RealtimeEvent::ResponseDone(_) => {} + RealtimeEvent::ConversationItemAdded(item) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } + RealtimeEvent::ConversationItemDone { .. } | RealtimeEvent::NoopRequested(_) => {} + RealtimeEvent::HandoffRequested(handoff) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item: serde_json::json!({ + "type": "handoff_request", + "handoff_id": handoff.handoff_id, + "item_id": handoff.item_id, + "input_transcript": handoff.input_transcript, + "active_transcript": handoff.active_transcript, + }), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } + RealtimeEvent::Error(message) => { + let notification = ThreadRealtimeErrorNotification { + thread_id: conversation_id.to_string(), + message, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeError(notification)) + .await; + } + }, + EventMsg::RealtimeConversationClosed(event) => { + let notification = ThreadRealtimeClosedNotification { + thread_id: conversation_id.to_string(), + reason: event.reason, + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeClosed(notification)) + .await; } EventMsg::ApplyPatchApprovalRequest(event) => { let permission_guard = thread_watch_manager .note_permission_requested(&conversation_id.to_string()) .await; - match api_version { - ApiVersion::V1 => { - let params = ApplyPatchApprovalParams { - conversation_id, - call_id: event.call_id.clone(), - file_changes: event.changes.clone(), - reason: event.reason.clone(), - grant_root: event.grant_root.clone(), - }; - let (_pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::ApplyPatchApproval(params)) - .await; - let call_id = event.call_id.clone(); - tokio::spawn(async move { - let _permission_guard = permission_guard; - on_patch_approval_response(call_id, rx, conversation).await; - }); - } - ApiVersion::V2 => { - // Until we migrate the core to be aware of a first class FileChangeItem - // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. - let item_id = event.call_id.clone(); - let patch_changes = convert_patch_changes(&event.changes); - let first_start = { - let mut state = thread_state.lock().await; - state - .turn_summary - .file_change_started - .insert(item_id.clone()) - }; - if first_start { - let item = build_file_change_approval_request_item(&event); - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - } - - let params = FileChangeRequestApprovalParams { - thread_id: conversation_id.to_string(), - turn_id: event.turn_id.clone(), - item_id: item_id.clone(), - reason: event.reason.clone(), - grant_root: event.grant_root.clone(), - }; - let (pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::FileChangeRequestApproval(params)) - .await; - tokio::spawn(async move { - on_file_change_request_approval_response( - event_turn_id, - conversation_id, - item_id, - patch_changes, - pending_request_id, - rx, - conversation, - outgoing, - thread_state.clone(), - permission_guard, - ) - .await; - }); - } + // Until we migrate the core to be aware of a first class FileChangeItem + // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. + let item_id = event.call_id.clone(); + let patch_changes = convert_patch_changes(&event.changes); + let first_start = { + let mut state = thread_state.lock().await; + state + .turn_summary + .file_change_started + .insert(item_id.clone()) + }; + if first_start { + let item = build_file_change_approval_request_item(&event); + let notification = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; } + + let params = FileChangeRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: event.turn_id.clone(), + item_id: item_id.clone(), + reason: event.reason.clone(), + grant_root: event.grant_root.clone(), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::FileChangeRequestApproval(params)) + .await; + tokio::spawn(async move { + on_file_change_request_approval_response( + event_turn_id, + conversation_id, + item_id, + patch_changes, + pending_request_id, + rx, + conversation, + outgoing, + thread_state.clone(), + permission_guard, + ) + .await; + }); } EventMsg::ExecApprovalRequest(ev) => { let permission_guard = thread_watch_manager .note_permission_requested(&conversation_id.to_string()) .await; - let approval_id_for_op = ev.effective_approval_id(); let available_decisions = ev .effective_available_decisions() .into_iter() @@ -687,406 +618,314 @@ pub(crate) async fn apply_bespoke_event_handling( parsed_cmd, .. } = ev; - match api_version { - ApiVersion::V1 => { - let params = ExecCommandApprovalParams { - conversation_id, - call_id: call_id.clone(), - approval_id, - command, - cwd: cwd.to_path_buf(), - reason, - parsed_cmd, - }; - let (_pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::ExecCommandApproval(params)) - .await; - tokio::spawn(async move { - let _permission_guard = permission_guard; - on_exec_approval_response( - approval_id_for_op, - event_turn_id, - rx, - conversation, - ) - .await; - }); - } - ApiVersion::V2 => { - let command_actions = parsed_cmd - .iter() - .cloned() - .map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd)) - .collect::>(); - let presentation = if let Some(network_approval_context) = - network_approval_context.map(V2NetworkApprovalContext::from) - { - CommandExecutionApprovalPresentation::Network(network_approval_context) - } else { - let command_string = shlex_join(&command); - let completion_item = CommandExecutionCompletionItem { - command: command_string, - cwd: cwd.clone(), - command_actions: command_actions.clone(), - }; - CommandExecutionApprovalPresentation::Command(completion_item) - }; - let (network_approval_context, command, cwd, command_actions, completion_item) = - match presentation { - CommandExecutionApprovalPresentation::Network( - network_approval_context, - ) => (Some(network_approval_context), None, None, None, None), - CommandExecutionApprovalPresentation::Command(completion_item) => ( - None, - Some(completion_item.command.clone()), - Some(completion_item.cwd.clone()), - Some(completion_item.command_actions.clone()), - Some(completion_item), - ), - }; - if approval_id.is_none() - && let Some(completion_item) = completion_item.as_ref() - { - start_command_execution_item( - &conversation_id, - event_turn_id.clone(), - call_id.clone(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, - &outgoing, - &thread_state, - ) - .await; + let command_actions = parsed_cmd + .iter() + .cloned() + .map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd)) + .collect::>(); + let presentation = if let Some(network_approval_context) = + network_approval_context.map(V2NetworkApprovalContext::from) + { + CommandExecutionApprovalPresentation::Network(network_approval_context) + } else { + let command_string = shlex_join(&command); + let completion_item = CommandExecutionCompletionItem { + command: command_string, + cwd: cwd.clone(), + command_actions: command_actions.clone(), + }; + CommandExecutionApprovalPresentation::Command(completion_item) + }; + let (network_approval_context, command, cwd, command_actions, completion_item) = + match presentation { + CommandExecutionApprovalPresentation::Network(network_approval_context) => { + (Some(network_approval_context), None, None, None, None) } - let proposed_execpolicy_amendment_v2 = - proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); - let proposed_network_policy_amendments_v2 = proposed_network_policy_amendments - .map(|amendments| { - amendments - .into_iter() - .map(V2NetworkPolicyAmendment::from) - .collect() - }); - let additional_permissions = - additional_permissions.map(V2AdditionalPermissionProfile::from); - - let params = CommandExecutionRequestApprovalParams { - thread_id: conversation_id.to_string(), - turn_id: turn_id.clone(), - item_id: call_id.clone(), - approval_id: approval_id.clone(), - reason, - network_approval_context, - command, - cwd, - command_actions, - additional_permissions, - proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, - proposed_network_policy_amendments: proposed_network_policy_amendments_v2, - available_decisions: Some(available_decisions), - }; - let (pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::CommandExecutionRequestApproval( - params, - )) - .await; - tokio::spawn(async move { - on_command_execution_request_approval_response( - event_turn_id, - conversation_id, - approval_id, - call_id, - completion_item, - pending_request_id, - rx, - conversation, - outgoing, - thread_state.clone(), - permission_guard, - ) - .await; - }); - } + CommandExecutionApprovalPresentation::Command(completion_item) => ( + None, + Some(completion_item.command.clone()), + Some(completion_item.cwd.clone()), + Some(completion_item.command_actions.clone()), + Some(completion_item), + ), + }; + if approval_id.is_none() + && let Some(completion_item) = completion_item.as_ref() + { + start_command_execution_item( + &conversation_id, + event_turn_id.clone(), + call_id.clone(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; } + let proposed_execpolicy_amendment_v2 = + proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); + let proposed_network_policy_amendments_v2 = + proposed_network_policy_amendments.map(|amendments| { + amendments + .into_iter() + .map(V2NetworkPolicyAmendment::from) + .collect() + }); + let additional_permissions = + additional_permissions.map(V2AdditionalPermissionProfile::from); + + let params = CommandExecutionRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: turn_id.clone(), + item_id: call_id.clone(), + approval_id: approval_id.clone(), + reason, + network_approval_context, + command, + cwd, + command_actions, + additional_permissions, + proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, + proposed_network_policy_amendments: proposed_network_policy_amendments_v2, + available_decisions: Some(available_decisions), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::CommandExecutionRequestApproval( + params, + )) + .await; + tokio::spawn(async move { + on_command_execution_request_approval_response( + event_turn_id, + conversation_id, + approval_id, + call_id, + completion_item, + pending_request_id, + rx, + conversation, + outgoing, + thread_state.clone(), + permission_guard, + ) + .await; + }); } EventMsg::RequestUserInput(request) => { - if matches!(api_version, ApiVersion::V2) { - let user_input_guard = thread_watch_manager - .note_user_input_requested(&conversation_id.to_string()) - .await; - let questions = request - .questions - .into_iter() - .map(|question| ToolRequestUserInputQuestion { - id: question.id, - header: question.header, - question: question.question, - is_other: question.is_other, - is_secret: question.is_secret, - options: question.options.map(|options| { - options - .into_iter() - .map(|option| ToolRequestUserInputOption { - label: option.label, - description: option.description, - }) - .collect() - }), - }) - .collect(); - let params = ToolRequestUserInputParams { - thread_id: conversation_id.to_string(), - turn_id: request.turn_id, - item_id: request.call_id, - questions, - }; - let (pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::ToolRequestUserInput(params)) - .await; - tokio::spawn(async move { - on_request_user_input_response( - event_turn_id, - pending_request_id, - rx, - conversation, - thread_state, - user_input_guard, - ) - .await; - }); - } else { - error!( - "request_user_input is only supported on api v2 (call_id: {})", - request.call_id - ); - let empty = CoreRequestUserInputResponse { - answers: HashMap::new(), - }; - if let Err(err) = conversation - .submit(Op::UserInputAnswer { - id: event_turn_id, - response: empty, - }) - .await - { - error!("failed to submit UserInputAnswer: {err}"); - } - } + let user_input_guard = thread_watch_manager + .note_user_input_requested(&conversation_id.to_string()) + .await; + let questions = request + .questions + .into_iter() + .map(|question| ToolRequestUserInputQuestion { + id: question.id, + header: question.header, + question: question.question, + is_other: question.is_other, + is_secret: question.is_secret, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| ToolRequestUserInputOption { + label: option.label, + description: option.description, + }) + .collect() + }), + }) + .collect(); + let params = ToolRequestUserInputParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id, + item_id: request.call_id, + questions, + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput(params)) + .await; + tokio::spawn(async move { + on_request_user_input_response( + event_turn_id, + pending_request_id, + rx, + conversation, + thread_state, + user_input_guard, + ) + .await; + }); } EventMsg::ElicitationRequest(request) => { - if matches!(api_version, ApiVersion::V2) { - let permission_guard = thread_watch_manager - .note_permission_requested(&conversation_id.to_string()) - .await; - let turn_id = match request.turn_id.clone() { - Some(turn_id) => Some(turn_id), - None => { - let state = thread_state.lock().await; - state.active_turn_snapshot().map(|turn| turn.id) + let permission_guard = thread_watch_manager + .note_permission_requested(&conversation_id.to_string()) + .await; + let turn_id = match request.turn_id.clone() { + Some(turn_id) => Some(turn_id), + None => { + let state = thread_state.lock().await; + state.active_turn_snapshot().map(|turn| turn.id) + } + }; + let server_name = request.server_name.clone(); + let request_body = match request.request.try_into() { + Ok(request_body) => request_body, + Err(err) => { + error!( + error = %err, + server_name, + request_id = ?request.id, + "failed to parse typed MCP elicitation schema" + ); + if let Err(err) = conversation + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: codex_protocol::approvals::ElicitationAction::Cancel, + content: None, + meta: None, + }) + .await + { + error!("failed to submit ResolveElicitation: {err}"); } - }; - let server_name = request.server_name.clone(); - let request_body = match request.request.try_into() { - Ok(request_body) => request_body, - Err(err) => { - error!( - error = %err, - server_name, - request_id = ?request.id, - "failed to parse typed MCP elicitation schema" - ); - if let Err(err) = conversation - .submit(Op::ResolveElicitation { - server_name: request.server_name, - request_id: request.id, - decision: codex_protocol::approvals::ElicitationAction::Cancel, - content: None, - meta: None, - }) - .await - { - error!("failed to submit ResolveElicitation: {err}"); - } - return; - } - }; - let params = McpServerElicitationRequestParams { - thread_id: conversation_id.to_string(), - turn_id, - server_name: request.server_name.clone(), - request: request_body, - }; - let (pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::McpServerElicitationRequest(params)) - .await; - tokio::spawn(async move { - on_mcp_server_elicitation_response( - request.server_name, - request.id, - pending_request_id, - rx, - conversation, - thread_state, - permission_guard, - ) - .await; - }); - } + return; + } + }; + let params = McpServerElicitationRequestParams { + thread_id: conversation_id.to_string(), + turn_id, + server_name: request.server_name.clone(), + request: request_body, + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::McpServerElicitationRequest(params)) + .await; + tokio::spawn(async move { + on_mcp_server_elicitation_response( + request.server_name, + request.id, + pending_request_id, + rx, + conversation, + thread_state, + permission_guard, + ) + .await; + }); } EventMsg::RequestPermissions(request) => { - if matches!(api_version, ApiVersion::V2) { - let permission_guard = thread_watch_manager - .note_permission_requested(&conversation_id.to_string()) - .await; - let requested_permissions = request.permissions.clone(); - let request_cwd = match request.cwd.clone() { - Some(cwd) => cwd, - None => conversation.config_snapshot().await.cwd, - }; - let params = PermissionsRequestApprovalParams { - thread_id: conversation_id.to_string(), - turn_id: request.turn_id.clone(), - item_id: request.call_id.clone(), - cwd: request_cwd.clone(), - reason: request.reason, - permissions: request.permissions.into(), - }; - let (pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::PermissionsRequestApproval(params)) - .await; - let pending_response = PendingRequestPermissionsResponse { - call_id: request.call_id, - requested_permissions, - request_cwd, - pending_request_id, - receiver: rx, - request_permissions_guard: permission_guard, - }; - tokio::spawn(async move { - on_request_permissions_response(pending_response, conversation, thread_state) - .await; - }); - } else { - error!( - "request_permissions is only supported on api v2 (call_id: {})", - request.call_id - ); - let empty = CoreRequestPermissionsResponse { - permissions: Default::default(), - scope: CorePermissionGrantScope::Turn, - strict_auto_review: false, - }; - if let Err(err) = conversation - .submit(Op::RequestPermissionsResponse { - id: request.call_id, - response: empty, - }) - .await - { - error!("failed to submit RequestPermissionsResponse: {err}"); - } - } + let permission_guard = thread_watch_manager + .note_permission_requested(&conversation_id.to_string()) + .await; + let requested_permissions = request.permissions.clone(); + let request_cwd = match request.cwd.clone() { + Some(cwd) => cwd, + None => conversation.config_snapshot().await.cwd, + }; + let params = PermissionsRequestApprovalParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id.clone(), + item_id: request.call_id.clone(), + cwd: request_cwd.clone(), + reason: request.reason, + permissions: request.permissions.into(), + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::PermissionsRequestApproval(params)) + .await; + let pending_response = PendingRequestPermissionsResponse { + call_id: request.call_id, + requested_permissions, + request_cwd, + pending_request_id, + receiver: rx, + request_permissions_guard: permission_guard, + }; + tokio::spawn(async move { + on_request_permissions_response(pending_response, conversation, thread_state).await; + }); } EventMsg::DynamicToolCallRequest(request) => { - if matches!(api_version, ApiVersion::V2) { - let call_id = request.call_id; - let turn_id = request.turn_id; - let namespace = request.namespace; - let tool = request.tool; - let arguments = request.arguments; - let item = ThreadItem::DynamicToolCall { - id: call_id.clone(), - namespace: namespace.clone(), - tool: tool.clone(), - arguments: arguments.clone(), - status: DynamicToolCallStatus::InProgress, - content_items: None, - success: None, - duration_ms: None, - }; - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - let params = DynamicToolCallParams { - thread_id: conversation_id.to_string(), - turn_id: turn_id.clone(), - call_id: call_id.clone(), - namespace, - tool: tool.clone(), - arguments: arguments.clone(), - }; - let (_pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::DynamicToolCall(params)) - .await; - tokio::spawn(async move { - crate::dynamic_tools::on_call_response(call_id, rx, conversation).await; - }); - } else { - error!( - "dynamic tool calls are only supported on api v2 (call_id: {})", - request.call_id - ); - let call_id = request.call_id; - let _ = conversation - .submit(Op::DynamicToolResponse { - id: call_id.clone(), - response: CoreDynamicToolResponse { - content_items: vec![CoreDynamicToolCallOutputContentItem::InputText { - text: "dynamic tool calls require api v2".to_string(), - }], - success: false, - }, - }) - .await; - } + let call_id = request.call_id; + let turn_id = request.turn_id; + let namespace = request.namespace; + let tool = request.tool; + let arguments = request.arguments; + let item = ThreadItem::DynamicToolCall { + id: call_id.clone(), + namespace: namespace.clone(), + tool: tool.clone(), + arguments: arguments.clone(), + status: DynamicToolCallStatus::InProgress, + content_items: None, + success: None, + duration_ms: None, + }; + let notification = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: turn_id.clone(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + let params = DynamicToolCallParams { + thread_id: conversation_id.to_string(), + turn_id: turn_id.clone(), + call_id: call_id.clone(), + namespace, + tool: tool.clone(), + arguments: arguments.clone(), + }; + let (_pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::DynamicToolCall(params)) + .await; + tokio::spawn(async move { + crate::dynamic_tools::on_call_response(call_id, rx, conversation).await; + }); } EventMsg::DynamicToolCallResponse(response) => { - if matches!(api_version, ApiVersion::V2) { - let status = if response.success { - DynamicToolCallStatus::Completed - } else { - DynamicToolCallStatus::Failed - }; - let duration_ms = i64::try_from(response.duration.as_millis()).ok(); - let item = ThreadItem::DynamicToolCall { - id: response.call_id, - namespace: response.namespace, - tool: response.tool, - arguments: response.arguments, - status, - content_items: Some( - response - .content_items - .into_iter() - .map(|item| match item { - CoreDynamicToolCallOutputContentItem::InputText { text } => { - DynamicToolCallOutputContentItem::InputText { text } - } - CoreDynamicToolCallOutputContentItem::InputImage { image_url } => { - DynamicToolCallOutputContentItem::InputImage { image_url } - } - }) - .collect(), - ), - success: Some(response.success), - duration_ms, - }; - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: response.turn_id, - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; - } + let status = if response.success { + DynamicToolCallStatus::Completed + } else { + DynamicToolCallStatus::Failed + }; + let duration_ms = i64::try_from(response.duration.as_millis()).ok(); + let item = ThreadItem::DynamicToolCall { + id: response.call_id, + namespace: response.namespace, + tool: response.tool, + arguments: response.arguments, + status, + content_items: Some( + response + .content_items + .into_iter() + .map(|item| match item { + CoreDynamicToolCallOutputContentItem::InputText { text } => { + DynamicToolCallOutputContentItem::InputText { text } + } + CoreDynamicToolCallOutputContentItem::InputImage { image_url } => { + DynamicToolCallOutputContentItem::InputImage { image_url } + } + }) + .collect(), + ), + success: Some(response.success), + duration_ms, + }; + let notification = ItemCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: response.turn_id, + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; } // TODO(celia): properly construct McpToolCall TurnItem in core. EventMsg::McpToolCallBegin(begin_event) => { @@ -1397,16 +1236,6 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::ContextCompacted(..) => { // Core still fans out this deprecated event for legacy clients; // v2 clients receive the canonical ContextCompaction item instead. - if matches!(api_version, ApiVersion::V2) { - return; - } - let notification = ContextCompactedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - }; - outgoing - .send_server_notification(ServerNotification::ContextCompacted(notification)) - .await; } EventMsg::DeprecationNotice(event) => { let notification = DeprecationNoticeNotification { @@ -1590,28 +1419,24 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::HookStarted(event) => { - if let ApiVersion::V2 = api_version { - let notification = HookStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event.turn_id, - run: event.run.into(), - }; - outgoing - .send_server_notification(ServerNotification::HookStarted(notification)) - .await; - } + let notification = HookStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: event.turn_id, + run: event.run.into(), + }; + outgoing + .send_server_notification(ServerNotification::HookStarted(notification)) + .await; } EventMsg::HookCompleted(event) => { - if let ApiVersion::V2 = api_version { - let notification = HookCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event.turn_id, - run: event.run.into(), - }; - outgoing - .send_server_notification(ServerNotification::HookCompleted(notification)) - .await; - } + let notification = HookCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id: event.turn_id, + run: event.run.into(), + }; + outgoing + .send_server_notification(ServerNotification::HookCompleted(notification)) + .await; } EventMsg::ExitedReviewMode(review_event) => { let review = match review_event.review_output { @@ -1641,7 +1466,6 @@ pub(crate) async fn apply_bespoke_event_handling( } EventMsg::RawResponseItem(raw_response_item_event) => { maybe_emit_hook_prompt_item_completed( - api_version, conversation_id, &event_turn_id, &raw_response_item_event.item, @@ -1649,7 +1473,6 @@ pub(crate) async fn apply_bespoke_event_handling( ) .await; maybe_emit_raw_response_item_completed( - api_version, conversation_id, &event_turn_id, raw_response_item_event.item, @@ -1707,12 +1530,10 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::ExecCommandBegin(exec_command_begin_event) => { - if matches!(api_version, ApiVersion::V2) - && matches!( - exec_command_begin_event.source, - codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction - ) - { + if matches!( + exec_command_begin_event.source, + codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction + ) { // TerminalInteraction is the v2 surface for unified exec // stdin/poll events. Suppress the legacy CommandExecution // item so clients do not render the same wait twice. @@ -1821,12 +1642,10 @@ pub(crate) async fn apply_bespoke_event_handling( .command_execution_started .remove(&call_id); } - if matches!(api_version, ApiVersion::V2) - && matches!( - exec_command_end_event.source, - codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction - ) - { + if matches!( + exec_command_end_event.source, + codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction + ) { // The paired begin event is suppressed above; keep the // completion out of v2 as well so no orphan legacy item is // emitted for unified exec interactions. @@ -1848,12 +1667,7 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::TurnAborted(turn_aborted_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; - respond_to_pending_interrupts( - &thread_state, - &outgoing, - Some(turn_aborted_event.reason.clone()), - ) - .await; + respond_to_pending_interrupts(&thread_state, &outgoing).await; thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) @@ -1957,48 +1771,36 @@ pub(crate) async fn apply_bespoke_event_handling( } } EventMsg::ThreadNameUpdated(thread_name_event) => { - if let ApiVersion::V2 = api_version { - let notification = ThreadNameUpdatedNotification { - thread_id: thread_name_event.thread_id.to_string(), - thread_name: thread_name_event.thread_name, - }; - outgoing - .send_global_server_notification(ServerNotification::ThreadNameUpdated( - notification, - )) - .await; - } + let notification = ThreadNameUpdatedNotification { + thread_id: thread_name_event.thread_id.to_string(), + thread_name: thread_name_event.thread_name, + }; + outgoing + .send_global_server_notification(ServerNotification::ThreadNameUpdated( + notification, + )) + .await; } EventMsg::ThreadGoalUpdated(thread_goal_event) => { - if let ApiVersion::V2 = api_version { - let notification = ThreadGoalUpdatedNotification { - thread_id: thread_goal_event.thread_id.to_string(), - turn_id: thread_goal_event.turn_id, - goal: thread_goal_event.goal.clone().into(), - }; - outgoing - .send_global_server_notification(ServerNotification::ThreadGoalUpdated( - notification, - )) - .await; - } + let notification = ThreadGoalUpdatedNotification { + thread_id: thread_goal_event.thread_id.to_string(), + turn_id: thread_goal_event.turn_id, + goal: thread_goal_event.goal.clone().into(), + }; + outgoing + .send_global_server_notification(ServerNotification::ThreadGoalUpdated( + notification, + )) + .await; } EventMsg::TurnDiff(turn_diff_event) => { - handle_turn_diff( - conversation_id, - &event_turn_id, - turn_diff_event, - api_version, - &outgoing, - ) - .await; + handle_turn_diff(conversation_id, &event_turn_id, turn_diff_event, &outgoing).await; } EventMsg::PlanUpdate(plan_update_event) => { handle_turn_plan_update( conversation_id, &event_turn_id, plan_update_event, - api_version, &outgoing, ) .await; @@ -2017,44 +1819,38 @@ async fn handle_turn_diff( conversation_id: ThreadId, event_turn_id: &str, turn_diff_event: TurnDiffEvent, - api_version: ApiVersion, outgoing: &ThreadScopedOutgoingMessageSender, ) { - if let ApiVersion::V2 = api_version { - let notification = TurnDiffUpdatedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.to_string(), - diff: turn_diff_event.unified_diff, - }; - outgoing - .send_server_notification(ServerNotification::TurnDiffUpdated(notification)) - .await; - } + let notification = TurnDiffUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.to_string(), + diff: turn_diff_event.unified_diff, + }; + outgoing + .send_server_notification(ServerNotification::TurnDiffUpdated(notification)) + .await; } async fn handle_turn_plan_update( conversation_id: ThreadId, event_turn_id: &str, plan_update_event: UpdatePlanArgs, - api_version: ApiVersion, outgoing: &ThreadScopedOutgoingMessageSender, ) { // `update_plan` is a todo/checklist tool; it is not related to plan-mode updates - if let ApiVersion::V2 = api_version { - let notification = TurnPlanUpdatedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.to_string(), - explanation: plan_update_event.explanation, - plan: plan_update_event - .plan - .into_iter() - .map(TurnPlanStep::from) - .collect(), - }; - outgoing - .send_server_notification(ServerNotification::TurnPlanUpdated(notification)) - .await; - } + let notification = TurnPlanUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.to_string(), + explanation: plan_update_event.explanation, + plan: plan_update_event + .plan + .into_iter() + .map(TurnPlanStep::from) + .collect(), + }; + outgoing + .send_server_notification(ServerNotification::TurnPlanUpdated(notification)) + .await; } struct TurnCompletionMetadata { @@ -2208,16 +2004,11 @@ async fn complete_command_execution_item( } async fn maybe_emit_raw_response_item_completed( - api_version: ApiVersion, conversation_id: ThreadId, turn_id: &str, item: codex_protocol::models::ResponseItem, outgoing: &ThreadScopedOutgoingMessageSender, ) { - let ApiVersion::V2 = api_version else { - return; - }; - let notification = RawResponseItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id: turn_id.to_string(), @@ -2229,16 +2020,11 @@ async fn maybe_emit_raw_response_item_completed( } pub(crate) async fn maybe_emit_hook_prompt_item_completed( - api_version: ApiVersion, conversation_id: ThreadId, turn_id: &str, item: &codex_protocol::models::ResponseItem, outgoing: &ThreadScopedOutgoingMessageSender, ) { - let ApiVersion::V2 = api_version else { - return; - }; - let codex_protocol::models::ResponseItem::Message { role, content, id, .. } = item @@ -2354,27 +2140,16 @@ async fn handle_thread_rollback_failed( async fn respond_to_pending_interrupts( thread_state: &Arc>, outgoing: &ThreadScopedOutgoingMessageSender, - abort_reason: Option, ) { let pending = { let mut state = thread_state.lock().await; std::mem::take(&mut state.pending_interrupts) }; - for (rid, ver) in pending { - match ver { - ApiVersion::V1 => { - let Some(abort_reason) = abort_reason.clone() else { - debug_assert!(false, "v1 interrupts only resolve from TurnAborted"); - continue; - }; - let response = InterruptConversationResponse { abort_reason }; - outgoing.send_response(rid, response).await; - } - ApiVersion::V2 => { - outgoing.send_response(rid, TurnInterruptResponse {}).await; - } - } + for request_id in pending { + outgoing + .send_response(request_id, TurnInterruptResponse {}) + .await; } } @@ -2415,105 +2190,6 @@ async fn handle_error( state.turn_summary.last_error = Some(error); } -async fn on_patch_approval_response( - call_id: String, - receiver: oneshot::Receiver, - codex: Arc, -) { - let response = receiver.await; - let value = match response { - Ok(Ok(value)) => value, - Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, - Ok(Err(err)) => { - error!("request failed with client error: {err:?}"); - if let Err(submit_err) = codex - .submit(Op::PatchApproval { - id: call_id.clone(), - decision: ReviewDecision::Denied, - }) - .await - { - error!("failed to submit denied PatchApproval after request failure: {submit_err}"); - } - return; - } - Err(err) => { - error!("request failed: {err:?}"); - if let Err(submit_err) = codex - .submit(Op::PatchApproval { - id: call_id.clone(), - decision: ReviewDecision::Denied, - }) - .await - { - error!("failed to submit denied PatchApproval after request failure: {submit_err}"); - } - return; - } - }; - - let response = - serde_json::from_value::(value).unwrap_or_else(|err| { - error!("failed to deserialize ApplyPatchApprovalResponse: {err}"); - ApplyPatchApprovalResponse { - decision: ReviewDecision::Denied, - } - }); - - if let Err(err) = codex - .submit(Op::PatchApproval { - id: call_id, - decision: response.decision, - }) - .await - { - error!("failed to submit PatchApproval: {err}"); - } -} - -async fn on_exec_approval_response( - call_id: String, - turn_id: String, - receiver: oneshot::Receiver, - conversation: Arc, -) { - let response = receiver.await; - let value = match response { - Ok(Ok(value)) => value, - Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, - Ok(Err(err)) => { - error!("request failed with client error: {err:?}"); - return; - } - Err(err) => { - error!("request failed: {err:?}"); - return; - } - }; - - // Try to deserialize `value` and then make the appropriate call to `codex`. - let response = - serde_json::from_value::(value).unwrap_or_else(|err| { - error!("failed to deserialize ExecCommandApprovalResponse: {err}"); - // If we cannot deserialize the response, we deny the request to be - // conservative. - ExecCommandApprovalResponse { - decision: ReviewDecision::Denied, - } - }); - - if let Err(err) = conversation - .submit(Op::ExecApproval { - id: call_id, - turn_id: Some(turn_id), - decision: response.decision, - }) - .await - { - error!("failed to submit ExecApproval: {err}"); - } -} - async fn on_request_user_input_response( event_turn_id: String, pending_request_id: RequestId, @@ -3287,7 +2963,6 @@ mod tests { self.thread_state.clone(), self.thread_watch_manager.clone(), Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), - ApiVersion::V2, "test-provider".to_string(), &self.codex_home, ) @@ -4425,14 +4100,7 @@ mod tests { let conversation_id = ThreadId::new(); - handle_turn_plan_update( - conversation_id, - "turn-123", - update, - ApiVersion::V2, - &outgoing, - ) - .await; + handle_turn_plan_update(conversation_id, "turn-123", update, &outgoing).await; let msg = recv_broadcast_message(&mut rx).await?; match msg { @@ -4908,7 +4576,6 @@ mod tests { TurnDiffEvent { unified_diff: unified_diff.clone(), }, - ApiVersion::V2, &outgoing, ) .await; @@ -4928,35 +4595,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> { - let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new( - tx, - codex_analytics::AnalyticsEventsClient::disabled(), - )); - let outgoing = ThreadScopedOutgoingMessageSender::new( - outgoing, - vec![ConnectionId(1)], - ThreadId::new(), - ); - let conversation_id = ThreadId::new(); - - handle_turn_diff( - conversation_id, - "turn-1", - TurnDiffEvent { - unified_diff: "diff".to_string(), - }, - ApiVersion::V1, - &outgoing, - ) - .await; - - assert!(rx.try_recv().is_err(), "no messages expected"); - Ok(()) - } - #[tokio::test] async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); @@ -4976,14 +4614,7 @@ mod tests { ]) .expect("hook prompt message"); - maybe_emit_hook_prompt_item_completed( - ApiVersion::V2, - conversation_id, - "turn-1", - &item, - &outgoing, - ) - .await; + maybe_emit_hook_prompt_item_completed(conversation_id, "turn-1", &item, &outgoing).await; let msg = recv_broadcast_message(&mut rx).await?; match msg { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e53b5e2c20..853e765646 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -42,7 +42,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; @@ -76,6 +76,9 @@ use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; @@ -106,7 +109,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfile as ApiPermissionProfile; +use codex_app_server_protocol::PermissionProfileModificationParams; +use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; @@ -233,6 +237,7 @@ use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; use codex_config::CloudRequirementsLoadError; use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::ConfigLayerStack; use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; @@ -319,7 +324,6 @@ use codex_mcp::resolve_oauth_scopes; use codex_memories_write::clear_memory_roots_contents; use codex_model_provider::ProviderAccountError; use codex_model_provider::create_model_provider; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; @@ -514,6 +518,7 @@ impl Drop for ActiveLogin { } /// Handles JSON-RPC messages for Codex threads (and legacy conversation APIs). +#[derive(Clone)] pub(crate) struct CodexMessageProcessor { auth_manager: Arc, thread_manager: Arc, @@ -540,14 +545,6 @@ pub(crate) struct CodexMessageProcessor { log_db: Option, } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) enum ApiVersion { - #[allow(dead_code)] - V1, - #[default] - V2, -} - #[derive(Clone)] struct ListenerTaskContext { thread_manager: Arc, @@ -719,6 +716,23 @@ impl CodexMessageProcessor { .await } + /// Resolve a caller-provided cwd into the absolute cwd and matching config layers + /// so list-style RPCs share the same per-cwd error handling. + async fn resolve_cwd_config( + &self, + cwd: &Path, + ) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> { + let cwd_abs = + AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?; + let config_layer_stack = self + .config_manager + .load_config_layers_for_cwd(cwd_abs.clone()) + .await + .map_err(|err| err.to_string())?; + + Ok((cwd_abs, config_layer_stack)) + } + pub(crate) fn handle_config_mutation(&self) { self.clear_plugin_related_caches(); } @@ -881,15 +895,13 @@ impl CodexMessageProcessor { fn normalize_turn_start_collaboration_mode( &self, mut collaboration_mode: CollaborationMode, - collaboration_modes_config: CollaborationModesConfig, ) -> CollaborationMode { if collaboration_mode.settings.developer_instructions.is_none() - && let Some(instructions) = - builtin_collaboration_mode_presets(collaboration_modes_config) - .into_iter() - .find(|preset| preset.mode == Some(collaboration_mode.mode)) - .and_then(|preset| preset.developer_instructions.flatten()) - .filter(|instructions| !instructions.is_empty()) + && let Some(instructions) = builtin_collaboration_mode_presets() + .into_iter() + .find(|preset| preset.mode == Some(collaboration_mode.mode)) + .and_then(|preset| preset.developer_instructions.flatten()) + .filter(|instructions| !instructions.is_empty()) { collaboration_mode.settings.developer_instructions = Some(instructions); } @@ -1086,6 +1098,10 @@ impl CodexMessageProcessor { self.skills_list(to_connection_request_id(request_id), params) .await; } + ClientRequest::HooksList { request_id, params } => { + self.hooks_list(to_connection_request_id(request_id), params) + .await; + } ClientRequest::MarketplaceAdd { request_id, params } => { self.marketplace_add(to_connection_request_id(request_id), params) .await; @@ -1338,8 +1354,11 @@ impl CodexMessageProcessor { self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) .await; } - LoginAccountParams::Chatgpt => { - self.login_chatgpt_v2(request_id).await; + LoginAccountParams::Chatgpt { + codex_streamlined_login, + } => { + self.login_chatgpt_v2(request_id, codex_streamlined_login) + .await; } LoginAccountParams::ChatgptDeviceCode => { self.login_chatgpt_device_code_v2(request_id).await; @@ -1441,6 +1460,7 @@ impl CodexMessageProcessor { // Build options for a ChatGPT login attempt; performs validation. async fn login_chatgpt_common( &self, + codex_streamlined_login: bool, ) -> std::result::Result { let config = self.config.as_ref(); @@ -1458,6 +1478,7 @@ impl CodexMessageProcessor { let opts = LoginServerOptions { open_browser: false, + codex_streamlined_login, ..LoginServerOptions::new( config.codex_home.to_path_buf(), CLIENT_ID.to_string(), @@ -1496,13 +1517,20 @@ impl CodexMessageProcessor { } } - async fn login_chatgpt_v2(&self, request_id: ConnectionRequestId) { - let result = self.login_chatgpt_response().await; + async fn login_chatgpt_v2( + &self, + request_id: ConnectionRequestId, + codex_streamlined_login: bool, + ) { + let result = self.login_chatgpt_response(codex_streamlined_login).await; self.outgoing.send_result(request_id, result).await; } - async fn login_chatgpt_response(&self) -> Result { - let opts = self.login_chatgpt_common().await?; + async fn login_chatgpt_response( + &self, + codex_streamlined_login: bool, + ) -> Result { + let opts = self.login_chatgpt_common(codex_streamlined_login).await?; let server = run_login_server(opts) .map_err(|err| internal_error(format!("failed to start login server: {err}")))?; let login_id = Uuid::new_v4(); @@ -1573,7 +1601,9 @@ impl CodexMessageProcessor { async fn login_chatgpt_device_code_response( &self, ) -> Result { - let opts = self.login_chatgpt_common().await?; + let opts = self + .login_chatgpt_common(/*codex_streamlined_login*/ false) + .await?; let device_code = request_device_code(&opts) .await .map_err(Self::login_chatgpt_device_code_start_error)?; @@ -2118,7 +2148,7 @@ impl CodexMessageProcessor { let result = self .exec_one_off_command_inner(request_id.clone(), params) .await - .map(|()| None::); + .map(|()| None::); self.send_optional_result(request_id, result).await; } @@ -2409,7 +2439,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, config, service_name, base_instructions, @@ -2423,11 +2453,11 @@ impl CodexMessageProcessor { environments, persist_extended_history, } = params; - if sandbox.is_some() && permission_profile.is_some() { + if sandbox.is_some() && permissions.is_some() { self.outgoing .send_error( request_id, - invalid_request("`permissionProfile` cannot be combined with `sandbox`"), + invalid_request("`permissions` cannot be combined with `sandbox`"), ) .await; return; @@ -2462,7 +2492,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, base_instructions, developer_instructions, personality, @@ -2521,7 +2551,7 @@ impl CodexMessageProcessor { /*approval_policy*/ None, /*approvals_reviewer*/ None, /*sandbox*/ None, - /*permission_profile*/ None, + /*permissions*/ None, /*base_instructions*/ None, /*developer_instructions*/ None, /*personality*/ None, @@ -2795,7 +2825,6 @@ impl CodexMessageProcessor { thread_id, request_id.connection_id, experimental_raw_events, - ApiVersion::V2, ) .instrument(tracing::info_span!( "app_server.thread_start.attach_listener", @@ -2833,8 +2862,9 @@ impl CodexMessageProcessor { &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), ); - let permission_profile = - thread_response_permission_profile(config_snapshot.permission_profile); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); let response = ThreadStartResponse { thread: thread.clone(), @@ -2846,7 +2876,8 @@ impl CodexMessageProcessor { approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, - permission_profile, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; Ok::<_, JSONRPCErrorError>((response, thread_started_notification(thread))) @@ -2855,16 +2886,6 @@ impl CodexMessageProcessor { match result { Ok((response, notif)) => { - listener_task_context - .analytics_events_client - .track_response( - request_id.connection_id.0, - ClientResponse::ThreadStart { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); - listener_task_context .outgoing .send_response(request_id, response) @@ -2902,12 +2923,12 @@ impl CodexMessageProcessor { approval_policy: Option, approvals_reviewer: Option, sandbox: Option, - permission_profile: Option, + permissions: Option, base_instructions: Option, developer_instructions: Option, personality: Option, ) -> ConfigOverrides { - ConfigOverrides { + let mut overrides = ConfigOverrides { model, model_provider, service_tier, @@ -2917,14 +2938,15 @@ impl CodexMessageProcessor { approvals_reviewer: approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core), sandbox_mode: sandbox.map(SandboxMode::to_core), - permission_profile: permission_profile.map(Into::into), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, developer_instructions, personality, ..Default::default() - } + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions); + overrides } async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) { @@ -3544,7 +3566,7 @@ impl CodexMessageProcessor { let result = self .thread_rollback_start(&request_id, params) .await - .map(|()| None::); + .map(|()| None::); self.send_optional_result(request_id, result).await; } @@ -4177,7 +4199,6 @@ impl CodexMessageProcessor { thread_id, connection_id, /*raw_events_enabled*/ false, - ApiVersion::V2, ) .await, thread_id, @@ -4206,11 +4227,11 @@ impl CodexMessageProcessor { return; } - if params.sandbox.is_some() && params.permission_profile.is_some() { + if params.sandbox.is_some() && params.permissions.is_some() { self.outgoing .send_error( request_id, - invalid_request("`permissionProfile` cannot be combined with `sandbox`"), + invalid_request("`permissions` cannot be combined with `sandbox`"), ) .await; return; @@ -4243,7 +4264,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, config: mut request_overrides, base_instructions, developer_instructions, @@ -4278,7 +4299,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, base_instructions, developer_instructions, personality, @@ -4338,7 +4359,6 @@ impl CodexMessageProcessor { thread_id, request_id.connection_id, /*raw_events_enabled*/ false, - ApiVersion::V2, ) .await, thread_id, @@ -4385,8 +4405,9 @@ impl CodexMessageProcessor { &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), ); - let permission_profile = - thread_response_permission_profile(config_snapshot.permission_profile.clone()); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); let response = ThreadResumeResponse { thread, @@ -4398,16 +4419,10 @@ impl CodexMessageProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; - self.analytics_events_client.track_response( - request_id.connection_id.0, - ClientResponse::ThreadResume { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); let connection_id = request_id.connection_id; let token_usage_thread = include_turns.then(|| response.thread.clone()); @@ -4524,7 +4539,6 @@ impl CodexMessageProcessor { existing_thread_id, existing_thread.clone(), thread_state.clone(), - ApiVersion::V2, ) .await?; @@ -4805,7 +4819,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, config: cli_overrides, base_instructions, developer_instructions, @@ -4815,9 +4829,9 @@ impl CodexMessageProcessor { } = params; let include_turns = !exclude_turns; let result = async { - if sandbox.is_some() && permission_profile.is_some() { + if sandbox.is_some() && permissions.is_some() { return Err(invalid_request( - "`permissionProfile` cannot be combined with `sandbox`", + "`permissions` cannot be combined with `sandbox`", )); } @@ -4870,7 +4884,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - permission_profile, + permissions, base_instructions, developer_instructions, /*personality*/ None, @@ -4921,7 +4935,6 @@ impl CodexMessageProcessor { thread_id, request_id.connection_id, /*raw_events_enabled*/ false, - ApiVersion::V2, ) .await, thread_id, @@ -4986,8 +4999,9 @@ impl CodexMessageProcessor { &config_snapshot.permission_profile, config_snapshot.cwd.as_path(), ); - let permission_profile = - thread_response_permission_profile(config_snapshot.permission_profile); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); let response = ThreadForkResponse { thread: thread.clone(), @@ -4999,7 +5013,8 @@ impl CodexMessageProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -5020,14 +5035,6 @@ impl CodexMessageProcessor { return; } }; - self.analytics_events_client.track_response( - request_id.connection_id.0, - ClientResponse::ThreadFork { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); - let connection_id = request_id.connection_id; let token_usage_thread = include_turns.then(|| response.thread.clone()); self.outgoing.send_response(request_id, response).await; @@ -5811,7 +5818,7 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, result: Result, JSONRPCErrorError>, ) where - T: serde::Serialize, + T: Into, { match result { Ok(Some(response)) => self.outgoing.send_response(request_id, response).await, @@ -6259,43 +6266,24 @@ impl CodexMessageProcessor { .map(|environment| environment.get_filesystem()); let mut data = Vec::new(); for cwd in cwds { + let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await { + Ok(resolved) => resolved, + Err(message) => { + let error_path = cwd.clone(); + data.push(codex_app_server_protocol::SkillsListEntry { + cwd, + skills: Vec::new(), + errors: vec![codex_app_server_protocol::SkillErrorInfo { + path: error_path, + message, + }], + }); + continue; + } + }; let extra_roots = extra_roots_by_cwd .get(&cwd) .map_or(&[][..], std::vec::Vec::as_slice); - let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) { - Ok(path) => path, - Err(err) => { - let error_path = cwd.clone(); - data.push(codex_app_server_protocol::SkillsListEntry { - cwd, - skills: Vec::new(), - errors: vec![codex_app_server_protocol::SkillErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; - let config_layer_stack = match self - .config_manager - .load_config_layers_for_cwd(cwd_abs.clone()) - .await - { - Ok(config_layer_stack) => config_layer_stack, - Err(err) => { - let error_path = cwd.clone(); - data.push(codex_app_server_protocol::SkillsListEntry { - cwd, - skills: Vec::new(), - errors: vec![codex_app_server_protocol::SkillErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; let effective_skill_roots = if workspace_codex_plugins_enabled { plugins_manager .effective_skill_roots_for_layer_stack(&config_layer_stack, &config) @@ -6327,6 +6315,86 @@ impl CodexMessageProcessor { } Ok(SkillsListResponse { data }) } + + async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) { + let result = self.hooks_list_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + /// Handle `hooks/list` by resolving hooks for each requested cwd. + async fn hooks_list_response( + &self, + params: HooksListParams, + ) -> Result { + let HooksListParams { cwds } = params; + let cwds = if cwds.is_empty() { + vec![self.config.cwd.to_path_buf()] + } else { + cwds + }; + + let auth = self.auth_manager.auth().await; + let plugins_manager = self.thread_manager.plugins_manager(); + let mut data = Vec::new(); + for cwd in cwds { + let config = match self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(cwd.clone()), + ) + .await + { + Ok(config) => config, + Err(err) => { + let error_path = cwd.clone(); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: Vec::new(), + warnings: Vec::new(), + errors: vec![codex_app_server_protocol::HookErrorInfo { + path: error_path, + message: err.to_string(), + }], + }); + continue; + } + }; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; + let plugins_enabled = + config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; + let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks) + { + plugins_manager + .plugins_for_layer_stack( + &config.config_layer_stack, + &config, + /*plugin_hooks_feature_enabled*/ true, + ) + .await + } else { + codex_core::plugins::PluginLoadOutcome::default() + }; + let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { + feature_enabled: config.features.enabled(Feature::CodexHooks), + config_layer_stack: Some(config.config_layer_stack), + plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), + plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), + ..Default::default() + }); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: hooks_to_info(&hooks.hooks), + warnings: hooks.warnings, + errors: Vec::new(), + }); + } + Ok(HooksListResponse { data }) + } + async fn marketplace_remove( &self, request_id: ConnectionRequestId, @@ -6490,13 +6558,9 @@ impl CodexMessageProcessor { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - let collaboration_modes_config = CollaborationModesConfig { - default_mode_request_user_input: thread - .enabled(Feature::DefaultModeRequestUserInput), - }; - let collaboration_mode = params.collaboration_mode.map(|mode| { - self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config) - }); + let collaboration_mode = params + .collaboration_mode + .map(|mode| self.normalize_turn_start_collaboration_mode(mode)); let environments: Option> = params.environments.map(|environments| { environments @@ -6525,7 +6589,7 @@ impl CodexMessageProcessor { || params.approval_policy.is_some() || params.approvals_reviewer.is_some() || params.sandbox_policy.is_some() - || params.permission_profile.is_some() + || params.permissions.is_some() || params.model.is_some() || params.service_tier.is_some() || params.effort.is_some() @@ -6533,9 +6597,9 @@ impl CodexMessageProcessor { || collaboration_mode.is_some() || params.personality.is_some(); - if params.sandbox_policy.is_some() && params.permission_profile.is_some() { + if params.sandbox_policy.is_some() && params.permissions.is_some() { return Err(invalid_request( - "`permissionProfile` cannot be combined with `sandboxPolicy`", + "`permissions` cannot be combined with `sandboxPolicy`", )); } @@ -6545,7 +6609,45 @@ impl CodexMessageProcessor { .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core); let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let permission_profile = params.permission_profile.map(Into::into); + let (permission_profile, active_permission_profile) = + if let Some(permissions) = params.permissions { + let snapshot = thread.config_snapshot().await; + let mut overrides = ConfigOverrides { + cwd: cwd.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides( + &mut overrides, + Some(permissions), + ); + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(snapshot.cwd.to_path_buf()), + ) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements + // disallow a configured profile. An explicit turn request + // is different: reject it before accepting user input. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid turn context override: {warning}" + ))); + } + ( + Some(config.permissions.permission_profile()), + config.permissions.active_permission_profile(), + ) + } else { + (None, None) + }; let model = params.model; let effort = params.effort.map(Some); let summary = params.summary; @@ -6563,6 +6665,7 @@ impl CodexMessageProcessor { approvals_reviewer, sandbox_policy: sandbox_policy.clone(), permission_profile: permission_profile.clone(), + active_permission_profile: active_permission_profile.clone(), windows_sandbox_level: None, model: model.clone(), effort, @@ -6589,6 +6692,7 @@ impl CodexMessageProcessor { approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level: None, model, effort, @@ -6645,13 +6749,6 @@ impl CodexMessageProcessor { match result { Ok(response) => { - self.analytics_events_client.track_response( - request_id.connection_id.0, - ClientResponse::TurnStart { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); self.outgoing.send_response(request_id, response).await; } Err(error) => { @@ -6822,13 +6919,6 @@ impl CodexMessageProcessor { match result { Ok(response) => { - self.analytics_events_client.track_response( - request_id.connection_id.0, - ClientResponse::TurnSteer { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); self.outgoing.send_response(request_id, response).await; } Err(error) => { @@ -6849,7 +6939,6 @@ impl CodexMessageProcessor { thread_id, request_id.connection_id, /*raw_events_enabled*/ false, - ApiVersion::V2, ) .await { @@ -7135,7 +7224,6 @@ impl CodexMessageProcessor { thread_id, request_id.connection_id, /*raw_events_enabled*/ false, - ApiVersion::V2, ) .await, thread_id, @@ -7262,9 +7350,7 @@ impl CodexMessageProcessor { { return Err(invalid_request("no active turn to interrupt")); } - thread_state - .pending_interrupts - .push((request_id.clone(), ApiVersion::V2)); + thread_state.pending_interrupts.push(request_id.clone()); } self.outgoing @@ -7287,7 +7373,7 @@ impl CodexMessageProcessor { let mut thread_state = thread_state.lock().await; thread_state .pending_interrupts - .retain(|(pending_request_id, _)| pending_request_id != &request_id); + .retain(|pending_request_id| pending_request_id != &request_id); } let interrupt_target = if is_startup_interrupt { "startup" @@ -7309,7 +7395,6 @@ impl CodexMessageProcessor { conversation_id: ThreadId, connection_id: ConnectionId, raw_events_enabled: bool, - api_version: ApiVersion, ) -> Result { Self::ensure_conversation_listener_task( ListenerTaskContext { @@ -7326,7 +7411,6 @@ impl CodexMessageProcessor { conversation_id, connection_id, raw_events_enabled, - api_version, ) .await } @@ -7340,7 +7424,6 @@ impl CodexMessageProcessor { conversation_id: ThreadId, connection_id: ConnectionId, raw_events_enabled: bool, - api_version: ApiVersion, ) -> Result { let conversation = match listener_task_context .thread_manager @@ -7385,7 +7468,6 @@ impl CodexMessageProcessor { conversation_id, conversation, thread_state, - api_version, ) .await { @@ -7427,7 +7509,6 @@ impl CodexMessageProcessor { conversation_id: ThreadId, conversation: Arc, thread_state: Arc>, - api_version: ApiVersion, ) -> Result<(), JSONRPCErrorError> { Self::ensure_listener_task_running_task( ListenerTaskContext { @@ -7444,7 +7525,6 @@ impl CodexMessageProcessor { conversation_id, conversation, thread_state, - api_version, ) .await } @@ -7454,7 +7534,6 @@ impl CodexMessageProcessor { conversation_id: ThreadId, conversation: Arc, thread_state: Arc>, - api_version: ApiVersion, ) -> Result<(), JSONRPCErrorError> { let (cancel_tx, mut cancel_rx) = oneshot::channel(); let Some(mut unloading_state) = UnloadingState::new( @@ -7546,7 +7625,6 @@ impl CodexMessageProcessor { && !raw_events_enabled { maybe_emit_hook_prompt_item_completed( - api_version, conversation_id, &event.id, &raw_response_item_event.item, @@ -7566,7 +7644,6 @@ impl CodexMessageProcessor { thread_state.clone(), thread_watch_manager.clone(), thread_list_state_permit.clone(), - api_version, fallback_model_provider.clone(), codex_home.as_path(), ) @@ -8285,13 +8362,15 @@ async fn handle_pending_thread_resume_request( approval_policy, approvals_reviewer, permission_profile, + active_permission_profile, cwd, reasoning_effort, .. } = pending.config_snapshot; let instruction_sources = pending.instruction_sources; let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); - let permission_profile = thread_response_permission_profile(permission_profile); + let active_permission_profile = + thread_response_active_permission_profile(active_permission_profile); let response = ThreadResumeResponse { thread, @@ -8303,7 +8382,8 @@ async fn handle_pending_thread_resume_request( approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox, - permission_profile, + permission_profile: Some(permission_profile.into()), + active_permission_profile, reasoning_effort, }; let token_usage_thread = pending.include_turns.then(|| response.thread.clone()); @@ -8530,15 +8610,11 @@ fn collect_resume_override_mismatches( )); } } - if let Some(requested_permission_profile) = request.permission_profile.as_ref() { - let requested_permission_profile = - codex_protocol::models::PermissionProfile::from(requested_permission_profile.clone()); - if requested_permission_profile != config_snapshot.permission_profile { - mismatch_details.push(format!( - "permission_profile requested={requested_permission_profile:?} active={:?}", - config_snapshot.permission_profile - )); - } + if request.permissions.is_some() { + mismatch_details.push(format!( + "permissions override was provided and ignored while running; active={:?}", + config_snapshot.active_permission_profile + )); } if let Some(requested_personality) = request.personality.as_ref() && config_snapshot.personality.as_ref() != Some(requested_personality) @@ -8648,6 +8724,24 @@ fn skills_to_info( .collect() } +fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { + hooks + .iter() + .map(|hook| HookMetadata { + event_name: hook.event_name.into(), + handler_type: hook.handler_type.into(), + matcher: hook.matcher.clone(), + command: hook.command.clone(), + timeout_sec: hook.timeout_sec, + status_message: hook.status_message.clone(), + source_path: hook.source_path.clone(), + source: hook.source.into(), + plugin_id: hook.plugin_id.clone(), + display_order: hook.display_order, + }) + .collect() +} + fn plugin_skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_skill_paths: &std::collections::HashSet, @@ -9427,10 +9521,29 @@ fn with_thread_spawn_agent_metadata( } } -fn thread_response_permission_profile( - permission_profile: codex_protocol::models::PermissionProfile, -) -> Option { - Some(permission_profile.into()) +fn thread_response_active_permission_profile( + active_permission_profile: Option, +) -> Option { + active_permission_profile.map(Into::into) +} + +fn apply_permission_profile_selection_to_config_overrides( + overrides: &mut ConfigOverrides, + permissions: Option, +) { + let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + return; + }; + overrides.default_permissions = Some(id); + overrides + .additional_writable_roots + .extend(modifications.unwrap_or_default().into_iter().map( + |modification| match modification { + PermissionProfileModificationParams::AdditionalWritableRoot { path } => { + path.to_path_buf() + } + }, + )); } fn thread_response_sandbox_policy( @@ -9458,6 +9571,13 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) return true; } + if matches!( + overrides.default_permissions.as_deref(), + Some(":workspace" | ":danger-no-sandbox") + ) { + return true; + } + overrides .permission_profile .as_ref() @@ -9974,23 +10094,6 @@ mod tests { ); } - #[test] - fn thread_response_permission_profile_preserves_enforcement() { - let full_access_profile = codex_protocol::models::PermissionProfile::Disabled; - let external_profile = codex_protocol::models::PermissionProfile::External { - network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, - }; - - assert_eq!( - thread_response_permission_profile(external_profile.clone()), - Some(external_profile.into()) - ); - assert_eq!( - thread_response_permission_profile(full_access_profile.clone()), - Some(full_access_profile.into()) - ); - } - #[test] fn requested_permissions_trust_project_uses_permission_profile_intent() { let cwd = test_path_buf("/tmp/project").abs(); @@ -10035,6 +10138,20 @@ mod tests { }, cwd.as_path() )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":workspace".to_string()), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":danger-no-sandbox".to_string()), + ..Default::default() + }, + cwd.as_path() + )); assert!(!requested_permissions_trust_project( &ConfigOverrides { permission_profile: Some(read_only_profile), @@ -10042,6 +10159,13 @@ mod tests { }, cwd.as_path() )); + assert!(!requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":read-only".to_string()), + ..Default::default() + }, + cwd.as_path() + )); } #[test] @@ -10212,7 +10336,7 @@ mod tests { approval_policy: None, approvals_reviewer: None, sandbox: None, - permission_profile: None, + permissions: None, config: None, base_instructions: None, developer_instructions: None, @@ -10227,6 +10351,7 @@ mod tests { approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, + active_permission_profile: None, cwd, ephemeral: false, reasoning_effort: None, diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 81eea3ec0f..20030dfe21 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -144,8 +144,24 @@ impl ExternalAgentConfigService { Ok(items) } - pub(crate) fn detect_recent_sessions(&self) -> io::Result> { - detect_recent_sessions(&self.external_agent_home, &self.codex_home) + pub(crate) fn external_agent_session_source_path( + &self, + path: &Path, + ) -> io::Result> { + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + return Ok(None); + } + let path = match fs::canonicalize(path) { + Ok(path) => path, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + let projects_root = match fs::canonicalize(self.external_agent_home.join("projects")) { + Ok(projects_root) => projects_root, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + Ok(path.starts_with(projects_root).then_some(path)) } pub(crate) async fn import( diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ac7c1510c8..0de3e2df4c 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -542,6 +542,7 @@ mod tests { }, }), mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: Some(CoreResidencyRequirement::Us), @@ -670,6 +671,7 @@ mod tests { feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -730,6 +732,7 @@ mod tests { feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, diff --git a/codex-rs/app-server/src/external_agent_config_api.rs b/codex-rs/app-server/src/external_agent_config_api.rs index f6c6c2f301..5b6e341c47 100644 --- a/codex-rs/app-server/src/external_agent_config_api.rs +++ b/codex-rs/app-server/src/external_agent_config_api.rs @@ -20,16 +20,19 @@ use codex_app_server_protocol::PluginsMigration; use codex_app_server_protocol::SubagentMigration; use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration; use codex_external_agent_sessions::PendingSessionImport; -use codex_external_agent_sessions::PrepareSessionImportsError; -use codex_external_agent_sessions::prepare_pending_session_imports; +use codex_external_agent_sessions::prepare_validated_session_imports; use codex_external_agent_sessions::record_imported_session; use codex_protocol::ThreadId; +use std::collections::HashSet; use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Semaphore; #[derive(Clone)] pub(crate) struct ExternalAgentConfigApi { codex_home: PathBuf, migration_service: ExternalAgentConfigService, + session_import_permits: Arc, } impl ExternalAgentConfigApi { @@ -37,6 +40,7 @@ impl ExternalAgentConfigApi { Self { migration_service: ExternalAgentConfigService::new(codex_home.clone()), codex_home, + session_import_permits: Arc::new(Semaphore::new(1)), } } @@ -134,18 +138,10 @@ impl ExternalAgentConfigApi { }) } - pub(crate) fn detect_recent_sessions( - &self, - ) -> Result, JSONRPCErrorError> { - self.migration_service - .detect_recent_sessions() - .map_err(|err| internal_error(err.to_string())) - } - - pub(crate) fn prepare_pending_session_imports( + pub(crate) fn validate_pending_session_imports( &self, params: &ExternalAgentConfigImportParams, - ) -> Result, JSONRPCErrorError> { + ) -> Result, JSONRPCErrorError> { let sessions = params .migration_items .iter() @@ -163,18 +159,32 @@ impl ExternalAgentConfigApi { title: session.title, }) .collect::>(); - let detected_sessions = if sessions.is_empty() { - Vec::new() - } else { - self.detect_recent_sessions()? - }; - prepare_pending_session_imports(&self.codex_home, sessions, detected_sessions).map_err( - |err| match err { - PrepareSessionImportsError::SessionNotDetected(_) => { - invalid_params(err.to_string()) - } - }, - ) + let mut selected_session_paths = HashSet::new(); + let mut selected_sessions = Vec::new(); + for session in sessions { + let Some(canonical_path) = self + .migration_service + .external_agent_session_source_path(&session.path) + .map_err(|err| internal_error(err.to_string()))? + else { + return Err(session_not_detected_error(&session.path)); + }; + if selected_session_paths.insert(canonical_path) { + selected_sessions.push(session); + } + } + Ok(selected_sessions) + } + + pub(crate) fn prepare_validated_session_imports( + &self, + sessions: Vec, + ) -> Vec { + prepare_validated_session_imports(&self.codex_home, sessions) + } + + pub(crate) fn session_import_permits(&self) -> Arc { + Arc::clone(&self.session_import_permits) } pub(crate) fn record_imported_session( @@ -301,3 +311,10 @@ impl ExternalAgentConfigApi { .map_err(|err| internal_error(err.to_string())) } } + +fn session_not_detected_error(path: &std::path::Path) -> JSONRPCErrorError { + invalid_params(format!( + "external agent session was not detected for import: {}", + path.display() + )) +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 56d5ff183a..aec4ec5091 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; @@ -78,7 +79,6 @@ use codex_login::default_client::get_codex_user_agent; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::default_client::set_default_originator; use codex_model_provider::create_model_provider; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; @@ -289,11 +289,6 @@ impl MessageProcessor { config.as_ref(), auth_manager.clone(), session_source, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, environment_manager, Some(analytics_events_client.clone()), )); @@ -732,15 +727,11 @@ impl MessageProcessor { return Err(invalid_request(experimental_required_message(reason))); } let connection_id = connection_request_id.connection_id; - if let ClientRequest::TurnStart { request_id, .. } - | ClientRequest::TurnSteer { request_id, .. } = &codex_request - { - self.analytics_events_client.track_request( - connection_id.0, - request_id.clone(), - codex_request.clone(), - ); - } + self.analytics_events_client.track_request( + connection_id.0, + connection_request_id.request_id.clone(), + &codex_request, + ); let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); @@ -992,7 +983,12 @@ impl MessageProcessor { params: ConfigValueWriteParams, ) { let result = self.config_api.write_value(params).await; - self.handle_config_mutation_result(request_id, result).await + self.handle_config_mutation_result( + request_id, + result, + ClientResponsePayload::ConfigValueWrite, + ) + .await } async fn handle_config_batch_write( @@ -1001,7 +997,12 @@ impl MessageProcessor { params: ConfigBatchWriteParams, ) { let result = self.config_api.batch_write(params).await; - self.handle_config_mutation_result(request_id, result).await; + self.handle_config_mutation_result( + request_id, + result, + ClientResponsePayload::ConfigBatchWrite, + ) + .await; } async fn handle_experimental_feature_enablement_set( @@ -1015,7 +1016,12 @@ impl MessageProcessor { .set_experimental_feature_enablement(params) .await; let is_ok = result.is_ok(); - self.handle_config_mutation_result(request_id, result).await; + self.handle_config_mutation_result( + request_id, + result, + ClientResponsePayload::ExperimentalFeatureEnablementSet, + ) + .await; if should_refresh_apps_list && is_ok { self.refresh_apps_list_after_experimental_feature_enablement_set() .await; @@ -1091,15 +1097,18 @@ impl MessageProcessor { }); } - async fn handle_config_mutation_result( + async fn handle_config_mutation_result( &self, request_id: ConnectionRequestId, result: std::result::Result, + wrap_success: impl FnOnce(T) -> ClientResponsePayload, ) { match result { Ok(response) => { self.handle_config_mutation().await; - self.outgoing.send_response(request_id, response).await; + self.outgoing + .send_response_as(request_id, wrap_success(response)) + .await; } Err(error) => self.outgoing.send_error(request_id, error).await, } @@ -1177,7 +1186,7 @@ impl MessageProcessor { device_key_requests_allowed: bool, run_request: F, ) where - R: serde::Serialize + Send + 'static, + R: Into + Send + 'static, F: FnOnce(DeviceKeyApi) -> Fut + Send + 'static, Fut: Future> + Send + 'static, { @@ -1209,30 +1218,28 @@ impl MessageProcessor { ExternalAgentConfigMigrationItemType::Plugins ) }); + let has_session_imports = params.migration_items.iter().any(|item| { + matches!( + item.item_type, + ExternalAgentConfigMigrationItemType::Sessions + ) + }); let pending_session_imports = self .external_agent_config_api - .prepare_pending_session_imports(¶ms)?; + .validate_pending_session_imports(¶ms)?; let pending_plugin_imports = self.external_agent_config_api.import(params).await?; if needs_runtime_refresh { self.handle_config_mutation().await; } - for pending_session_import in pending_session_imports { - let imported_thread_id = self - .codex_message_processor - .import_external_agent_session(pending_session_import.session) - .await?; - self.external_agent_config_api - .record_imported_session(&pending_session_import.source_path, imported_thread_id); - } self.outgoing .send_response(request_id, ExternalAgentConfigImportResponse {}) .await; - if !has_plugin_imports { + if !has_plugin_imports && !has_session_imports { return Ok(()); } - if pending_plugin_imports.is_empty() { + if pending_plugin_imports.is_empty() && pending_session_imports.is_empty() { self.outgoing .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( ExternalAgentConfigImportCompletedNotification {}, @@ -1242,25 +1249,64 @@ impl MessageProcessor { } let external_agent_config_api = self.external_agent_config_api.clone(); + let session_import_permits = external_agent_config_api.session_import_permits(); + let codex_message_processor = self.codex_message_processor.clone(); let outgoing = Arc::clone(&self.outgoing); let thread_manager = Arc::clone(&self.thread_manager); tokio::spawn(async move { - for pending_plugin_import in pending_plugin_imports { - match external_agent_config_api - .complete_pending_plugin_import(pending_plugin_import) - .await - { - Ok(()) => {} - Err(error) => { - tracing::warn!( - error = %error.message, - "external agent config plugin import failed" - ); + let session_external_agent_config_api = external_agent_config_api.clone(); + let plugin_external_agent_config_api = external_agent_config_api; + let session_imports = async move { + if !pending_session_imports.is_empty() { + let Ok(_session_import_permit) = session_import_permits.acquire_owned().await + else { + return; + }; + let pending_session_imports = session_external_agent_config_api + .prepare_validated_session_imports(pending_session_imports); + for pending_session_import in pending_session_imports { + match codex_message_processor + .import_external_agent_session(pending_session_import.session) + .await + { + Ok(imported_thread_id) => { + session_external_agent_config_api.record_imported_session( + &pending_session_import.source_path, + imported_thread_id, + ); + } + Err(error) => { + tracing::warn!( + error = %error.message, + path = %pending_session_import.source_path.display(), + "external agent session import failed" + ); + } + } } } + }; + let plugin_imports = async move { + for pending_plugin_import in pending_plugin_imports { + match plugin_external_agent_config_api + .complete_pending_plugin_import(pending_plugin_import) + .await + { + Ok(()) => {} + Err(error) => { + tracing::warn!( + error = %error.message, + "external agent config plugin import failed" + ); + } + } + } + }; + tokio::join!(session_imports, plugin_imports); + if has_plugin_imports { + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); } - thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); outgoing .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( ExternalAgentConfigImportCompletedNotification {}, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index bb247be59e..8caf1aaa96 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -761,7 +761,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { cwd: None, approval_policy: None, sandbox_policy: None, - permission_profile: None, + permissions: None, approvals_reviewer: None, model: None, service_tier: None, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 300a7044e5..34441f83a0 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -5,6 +5,7 @@ use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; use codex_analytics::AnalyticsEventsClient; +use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; @@ -188,11 +189,10 @@ impl ThreadScopedOutgoingMessageSender { .await } - pub(crate) async fn send_response( - &self, - request_id: ConnectionRequestId, - response: T, - ) { + pub(crate) async fn send_response(&self, request_id: ConnectionRequestId, response: T) + where + T: Into, + { self.outgoing.send_response(request_id, response).await; } @@ -482,21 +482,40 @@ impl OutgoingMessageSender { } } - pub(crate) async fn send_response( + pub(crate) async fn send_response(&self, request_id: ConnectionRequestId, response: T) + where + T: Into, + { + self.send_response_as(request_id, response.into()).await; + } + + pub(crate) async fn send_response_as( &self, request_id: ConnectionRequestId, - response: T, + response: ClientResponsePayload, ) { + let connection_id = request_id.connection_id; + let request_id_for_analytics = request_id.request_id.clone(); + let serialized_response = response + .into_jsonrpc_parts_and_payload(request_id.request_id.clone()) + .map(|(id, result, response)| { + if let Some(response) = response { + self.analytics_events_client.track_response( + connection_id.0, + request_id_for_analytics, + response, + ); + } + (id, result) + }); let request_context = self.take_request_context(&request_id).await; - match serde_json::to_value(response) { - Ok(result) => { - let outgoing_message = OutgoingMessage::Response(OutgoingResponse { - id: request_id.request_id.clone(), - result, - }); + + match serialized_response { + Ok((id, result)) => { + let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result }); self.send_outgoing_message_to_connection( request_context, - request_id.connection_id, + connection_id, outgoing_message, "response", ) @@ -592,11 +611,13 @@ impl OutgoingMessageSender { request_id: ConnectionRequestId, result: std::result::Result, ) where - T: Serialize, + T: Into, E: Into, { match result { - Ok(response) => self.send_response(request_id, response).await, + Ok(response) => { + self.send_response(request_id, response).await; + } Err(error) => self.send_error(request_id, error).await, } } @@ -966,7 +987,12 @@ mod tests { }; outgoing - .send_response(request_id.clone(), json!({ "ok": true })) + .send_response( + request_id.clone(), + ClientResponsePayload::ThreadArchive( + codex_app_server_protocol::ThreadArchiveResponse {}, + ), + ) .await; let envelope = timeout(Duration::from_secs(1), rx.recv()) @@ -985,7 +1011,7 @@ mod tests { panic!("expected response message"); }; assert_eq!(response.id, request_id.request_id); - assert_eq!(response.result, json!({ "ok": true })); + assert_eq!(response.result, json!({})); } other => panic!("expected targeted response envelope, got: {other:?}"), } @@ -1011,7 +1037,12 @@ mod tests { assert_eq!(outgoing.request_context_count().await, 1); outgoing - .send_response(request_id, json!({ "ok": true })) + .send_response( + request_id, + ClientResponsePayload::ThreadArchive( + codex_app_server_protocol::ThreadArchiveResponse {}, + ), + ) .await; assert_eq!(outgoing.request_context_count().await, 0); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 73d1c5961b..5122334843 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -22,10 +22,7 @@ use tokio::sync::oneshot; use tokio::sync::watch; use tracing::error; -type PendingInterruptQueue = Vec<( - ConnectionRequestId, - crate::codex_message_processor::ApiVersion, -)>; +type PendingInterruptQueue = Vec; pub(crate) struct PendingThreadResumeRequest { pub(crate) request_id: ConnectionRequestId, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 76e29d21a4..b513ada9a0 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; @@ -580,6 +581,15 @@ impl McpProcess { self.send_request("skills/list", params).await } + /// Send a `hooks/list` JSON-RPC request. + pub async fn send_hooks_list_request( + &mut self, + params: HooksListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("hooks/list", params).await + } + /// Send a `marketplace/add` JSON-RPC request. pub async fn send_marketplace_add_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 335489929d..395dff5668 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1582,7 +1582,7 @@ async fn workspace_settings_response( } else { Ok(Json(json!({ "beta_settings": { - "plugins": state.workspace_plugins_enabled + "enable_plugins": state.workspace_plugins_enabled } }))) } diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index 3c8a3e573e..f5914f0449 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -1,8 +1,7 @@ //! Validates that the collaboration mode list endpoint returns the expected default presets. //! //! The test drives the app server through the MCP harness and asserts that the list response -//! includes the plan and default modes with their default model and reasoning effort -//! settings, which keeps the API contract visible in one place. +//! includes the plan and default modes, which keeps the API contract visible in one place. #![allow(clippy::unwrap_used)] diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 54b1f319e9..a186485df6 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -125,7 +125,8 @@ async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_ .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( - ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), ) .mount(&server) .await; diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 1f84b3733d..f160de1a7b 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -26,6 +26,8 @@ use core_test_support::responses; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use tempfile::TempDir; +#[cfg(unix)] +use tokio::io::AsyncWriteExt; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); @@ -271,6 +273,12 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .await??; let response: ExternalAgentConfigImportResponse = to_response(response)?; assert_eq!(response, ExternalAgentConfigImportResponse {}); + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); let request_id = mcp .send_thread_list_request(ThreadListParams { @@ -313,7 +321,17 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .await??; let response: ThreadReadResponse = to_response(response)?; assert_eq!(response.thread.turns.len(), 1); - assert_eq!(response.thread.turns[0].items.len(), 2); + let items = &response.thread.turns[0].items; + assert_eq!(items.len(), 3); + assert_eq!( + items.last(), + Some(&ThreadItem::AgentMessage { + id: "item-3".into(), + text: "".into(), + phase: None, + memory_citation: None, + }) + ); let request_id = mcp .send_thread_resume_request(ThreadResumeParams { @@ -370,6 +388,92 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_agent_config_import_accepts_detected_session_payload_after_restart() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("unused").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + std::fs::write( + &session_path, + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + )?; + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": [{ + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": session_path, + "cwd": project_root, + "title": "first request" + }] + } + }] + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + assert_eq!(response.data.len(), 1); + + Ok(()) +} + #[tokio::test] async fn external_agent_config_import_skips_already_imported_session_versions() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("unused").await; @@ -423,6 +527,12 @@ async fn external_agent_config_import_skips_already_imported_session_versions() ) .await??; let _: ExternalAgentConfigImportResponse = to_response(response)?; + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); } let request_id = mcp @@ -450,6 +560,144 @@ async fn external_agent_config_import_skips_already_imported_session_versions() Ok(()) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_agent_config_import_returns_before_background_session_import_finishes() +-> Result<()> { + let server = create_mock_responses_server_repeating_assistant("unused").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let project_root = codex_home.path().join("repo"); + let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_path = session_dir.join("session.jsonl"); + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(&session_dir)?; + std::fs::write( + &session_path, + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": "first request" }, + }) + .to_string(), + )?; + + let project_config_dir = project_root.join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + let project_config = project_config_dir.join("config.toml"); + let status = std::process::Command::new("mkfifo") + .arg(&project_config) + .status()?; + assert!(status.success()); + + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/detect", + Some(serde_json::json!({ "includeHome": true })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let detected: ExternalAgentConfigDetectResponse = to_response(response)?; + assert_eq!(detected.items.len(), 1); + let detected_items = detected.items; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ "migrationItems": detected_items.clone() })), + ) + .await?; + let response: JSONRPCResponse = timeout( + Duration::from_secs(5), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + + assert!( + timeout( + Duration::from_millis(200), + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed") + ) + .await + .is_err(), + "session import completed before the blocked background import was unblocked" + ); + + let duplicate_request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ "migrationItems": detected_items })), + ) + .await?; + let response: JSONRPCResponse = timeout( + Duration::from_secs(5), + mcp.read_stream_until_response_message(RequestId::Integer(duplicate_request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + assert_eq!(response, ExternalAgentConfigImportResponse {}); + + let writer = tokio::spawn(async move { + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .open(&project_config) + .await?; + file.write_all(b"\n").await + }); + timeout(DEFAULT_TIMEOUT, writer).await???; + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); + + let request_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadListResponse = to_response(response)?; + assert_eq!(response.data.len(), 1); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_agent_config_import_rejects_undetected_session_paths() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("unused").await; @@ -625,6 +873,12 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ ) .await??; let _: ExternalAgentConfigImportResponse = to_response(response)?; + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + assert_eq!(notification.method, "externalAgentConfig/import/completed"); let request_id = mcp .send_thread_list_request(ThreadListParams { diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs new file mode 100644 index 0000000000..67d2821a18 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -0,0 +1,286 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookHandlerType; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HooksListEntry; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> { + std::fs::write( + codex_home.join("config.toml"), + r#"[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/listed-hook.py" +timeout = 5 +statusMessage = "running listed hook" +"#, + )?; + Ok(()) +} + +fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> Result<()> { + let plugin_root = codex_home.join("plugins/cache/test/demo/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("hooks"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"demo"}"#, + )?; + std::fs::write(plugin_root.join("hooks/hooks.json"), hooks_json)?; + std::fs::write( + codex_home.join("config.toml"), + r#"[features] +plugins = true +plugin_hooks = true +codex_hooks = true + +[plugins."demo@test"] +enabled = true +"#, + )?; + Ok(()) +} + +#[tokio::test] +async fn hooks_list_shows_discovered_hook() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_user_hook_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!( + data, + vec![HooksListEntry { + cwd: cwd.path().to_path_buf(), + hooks: vec![HookMetadata { + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("python3 /tmp/listed-hook.py".to_string()), + timeout_sec: 5, + status_message: Some("running listed hook".to_string()), + source_path: AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( + codex_home.path().join("config.toml") + )?,)?, + source: HookSource::User, + plugin_id: None, + display_order: 0, + }], + warnings: Vec::new(), + errors: Vec::new(), + }] + ); + Ok(()) +} + +#[tokio::test] +async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_plugin_hook_config( + codex_home.path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo plugin hook", + "timeout": 7, + "statusMessage": "running plugin hook" + } + ] + } + ] + } +}"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!( + data, + vec![HooksListEntry { + cwd: cwd.path().to_path_buf(), + hooks: vec![HookMetadata { + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("echo plugin hook".to_string()), + timeout_sec: 7, + status_message: Some("running plugin hook".to_string()), + source_path: AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( + codex_home + .path() + .join("plugins/cache/test/demo/local/hooks/hooks.json"), + )?,)?, + source: HookSource::Plugin, + plugin_id: Some("demo@test".to_string()), + display_order: 0, + }], + warnings: Vec::new(), + errors: Vec::new(), + }] + ); + Ok(()) +} + +#[tokio::test] +async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_plugin_hook_config(codex_home.path(), "{ not-json")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].hooks, Vec::new()); + assert_eq!(data[0].warnings.len(), 1); + assert!( + data[0].warnings[0].contains("failed to parse plugin hooks config"), + "unexpected warnings: {:?}", + data[0].warnings + ); + Ok(()) +} + +#[tokio::test] +async fn hooks_list_uses_each_cwds_effective_feature_enablement() -> Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +codex_hooks = false +"#, + )?; + std::fs::create_dir_all(workspace.path().join(".git"))?; + std::fs::create_dir_all(workspace.path().join(".codex"))?; + std::fs::write( + workspace.path().join(".codex/config.toml"), + r#"[features] +codex_hooks = true + +[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "echo project hook" +timeout = 5 +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![ + codex_home.path().to_path_buf(), + workspace.path().to_path_buf(), + ], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!( + data, + vec![ + HooksListEntry { + cwd: codex_home.path().to_path_buf(), + hooks: Vec::new(), + warnings: Vec::new(), + errors: Vec::new(), + }, + HooksListEntry { + cwd: workspace.path().to_path_buf(), + hooks: vec![HookMetadata { + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("echo project hook".to_string()), + timeout_sec: 5, + status_message: None, + source_path: AbsolutePathBuf::try_from( + workspace.path().join(".codex/config.toml"), + )?, + source: HookSource::Project, + plugin_id: None, + display_order: 0, + }], + warnings: Vec::new(), + errors: Vec::new(), + }, + ] + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index db1cc71d7e..59bfd2aa76 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -16,6 +16,7 @@ mod experimental_api; mod experimental_feature_list; mod external_agent_config; mod fs; +mod hooks_list; mod initialize; mod marketplace_add; mod marketplace_remove; 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 a115c83077..1b8069c7b5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -448,7 +448,8 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( - ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), ) .mount(&server) .await; 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 51e5d8dd88..09dd59f620 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -302,7 +302,8 @@ async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Re .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( - ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), ) .mount(&server) .await; @@ -391,7 +392,8 @@ async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<( .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( - ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":true}}"#), + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":true}}"#), ) .mount(&server) .await; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index e0f71f11b5..5a05816b0d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -196,7 +196,6 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl )?; mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "GLOBAL").await; - mount_empty_remote_installed_plugins(&server).await; Mock::given(method("POST")) .and(path(format!( @@ -269,7 +268,6 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> AuthCredentialsStoreMode::File, )?; mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "WORKSPACE").await; - mount_empty_remote_installed_plugins(&server).await; Mock::given(method("POST")) .and(path(format!( @@ -326,7 +324,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> } #[tokio::test] -async fn plugin_uninstall_posts_even_when_remote_detail_fetch_fails() -> Result<()> { +async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( @@ -342,19 +340,6 @@ async fn plugin_uninstall_posts_even_when_remote_detail_fetch_fails() -> Result< AuthCredentialsStoreMode::File, )?; - Mock::given(method("POST")) - .and(path(format!( - "/backend-api/plugins/{REMOTE_PLUGIN_ID}/uninstall" - ))) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(format!(r#"{{"id":"{REMOTE_PLUGIN_ID}","enabled":false}}"#)), - ) - .mount(&server) - .await; - let legacy_remote_plugin_cache_root = codex_home .path() .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); @@ -368,22 +353,29 @@ async fn plugin_uninstall_posts_even_when_remote_detail_fetch_fails() -> Result< plugin_id: REMOTE_PLUGIN_ID.to_string(), }) .await?; - let response: JSONRPCResponse = timeout( + let err = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; - let response: PluginUninstallResponse = to_response(response)?; - assert_eq!(response, PluginUninstallResponse {}); + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("remote plugin catalog request")); + wait_for_remote_plugin_request_count( + &server, + "GET", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}"), + /*expected_count*/ 1, + ) + .await?; wait_for_remote_plugin_request_count( &server, "POST", &format!("/plugins/{REMOTE_PLUGIN_ID}/uninstall"), - /*expected_count*/ 1, + /*expected_count*/ 0, ) .await?; - assert!(!legacy_remote_plugin_cache_root.exists()); + assert!(legacy_remote_plugin_cache_root.exists()); Ok(()) } @@ -557,24 +549,6 @@ async fn mount_remote_plugin_detail( .await; } -async fn mount_empty_remote_installed_plugins(server: &MockServer) { - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"{ - "plugins": [], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#, - )) - .mount(server) - .await; -} - async fn wait_for_remote_plugin_request_count( server: &MockServer, method_name: &str, diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 1105df37ca..b95adb9044 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -376,7 +376,8 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( - ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ResponseTemplate::new(200) + .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), ) .mount(&server) .await; @@ -666,7 +667,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( approval_policy: None, approvals_reviewer: None, sandbox: None, - permission_profile: None, + permissions: None, config: None, service_name: None, base_instructions: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3ff04d5022..aa507f39e4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -27,9 +27,6 @@ use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangePatchUpdatedNotification; use codex_app_server_protocol::FileChangeRequestApprovalResponse; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; @@ -38,9 +35,7 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; -use codex_app_server_protocol::PermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -67,7 +62,6 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; -use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -675,9 +669,8 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { } #[tokio::test] -async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> Result<()> { +async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() -> Result<()> { let codex_home = TempDir::new()?; - let disallowed_write_root = TempDir::new()?; create_config_toml( codex_home.path(), "http://localhost/unused", @@ -704,9 +697,6 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> ) .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let disallowed_write_root = AbsolutePathBuf::from_absolute_path(disallowed_write_root.path()) - .expect("tempdir path should be absolute"); - let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, @@ -714,17 +704,9 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> text: "Hello".to_string(), text_elements: Vec::new(), }], - permission_profile: Some(PermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: disallowed_write_root, - }, - access: FileSystemAccessMode::Write, - }], - glob_scan_max_depth: None, - }, + permissions: Some(PermissionProfileSelectionParams::Profile { + id: ":danger-no-sandbox".to_string(), + modifications: None, }), ..Default::default() }) @@ -749,7 +731,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> .await; assert!( turn_started.is_err(), - "did not expect a turn/started notification after rejected permissionProfile" + "did not expect a turn/started notification after rejected permissions selection" ); Ok(()) @@ -969,7 +951,7 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::from([(Feature::DefaultModeRequestUserInput, true)]), + &BTreeMap::default(), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -1029,13 +1011,15 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { let payload = request.body_json(); assert_eq!(payload["model"].as_str(), Some("mock-model-collab")); let payload_text = payload.to_string(); - assert!(payload_text.contains("The `request_user_input` tool is available in Default mode.")); + assert!(payload_text.contains( + "Use the `request_user_input` tool only when it is listed in the available tools" + )); Ok(()) } #[tokio::test] -async fn turn_start_uses_thread_feature_overrides_for_collaboration_mode_instructions_v2() +async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_description_v2() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1114,7 +1098,7 @@ async fn turn_start_uses_thread_feature_overrides_for_collaboration_mode_instruc let request = response_mock.single_request(); let payload_text = request.body_json().to_string(); - assert!(payload_text.contains("The `request_user_input` tool is available in Default mode.")); + assert!(payload_text.contains("This tool is only available in Default or Plan mode.")); Ok(()) } @@ -1831,7 +1815,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }), - permission_profile: None, + permissions: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1867,7 +1851,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), - permission_profile: None, + permissions: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), diff --git a/codex-rs/chatgpt/src/workspace_settings.rs b/codex-rs/chatgpt/src/workspace_settings.rs index 86e1a40871..a177215518 100644 --- a/codex-rs/chatgpt/src/workspace_settings.rs +++ b/codex-rs/chatgpt/src/workspace_settings.rs @@ -12,7 +12,7 @@ use crate::chatgpt_client::chatgpt_get_request_with_timeout; const WORKSPACE_SETTINGS_TIMEOUT: Duration = Duration::from_secs(10); const WORKSPACE_SETTINGS_CACHE_TTL: Duration = Duration::from_secs(15 * 60); -const CODEX_PLUGINS_BETA_SETTING: &str = "plugins"; +const CODEX_PLUGINS_BETA_SETTING: &str = "enable_plugins"; #[derive(Debug, Deserialize)] struct WorkspaceSettingsResponse { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 3ef1fbde24..7b6e7448d4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -62,7 +62,6 @@ use codex_features::is_known_feature_key; use codex_login::AuthManager; use codex_memories_write::clear_memory_roots_contents; use codex_models_manager::bundled_models_response; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::protocol::AskForApproval; use codex_protocol::user_input::UserInput; @@ -1408,8 +1407,7 @@ async fn run_debug_models_command( let config = Config::load_with_cli_overrides(cli_overrides).await?; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; - let models_manager = - build_models_manager(&config, auth_manager, CollaborationModesConfig::default()); + let models_manager = build_models_manager(&config, auth_manager); models_manager .raw_model_catalog(RefreshStrategy::OnlineIfUncached) .await diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index c548179646..6f283b43d0 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1207,6 +1207,7 @@ mod tests { feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1288,6 +1289,7 @@ mod tests { feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1320,6 +1322,7 @@ mod tests { feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1369,6 +1372,7 @@ mod tests { feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1403,6 +1407,36 @@ enabled = false ); } + #[tokio::test] + async fn fetch_cloud_requirements_parses_plugin_mcp_requirements_toml() { + let result = parse_for_fetch(Some( + r#" +[plugins."sample@test".mcp_servers.sample.identity] +command = "sample-mcp" +"#, + )); + + assert_eq!( + result, + Some(ConfigRequirementsToml { + plugins: Some(BTreeMap::from([( + "sample@test".to_string(), + codex_config::PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "sample".to_string(), + codex_config::McpServerRequirement { + identity: codex_config::McpServerIdentity::Command { + command: "sample-mcp".to_string(), + }, + }, + )])), + }, + )])), + ..Default::default() + }) + ); + } + #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_times_out() { let auth_manager = auth_manager_with_plan("enterprise").await; @@ -1454,6 +1488,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1533,6 +1568,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1610,6 +1646,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1815,6 +1852,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1854,6 +1892,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1913,6 +1952,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1968,6 +2008,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -2025,6 +2066,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -2083,6 +2125,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -2141,6 +2184,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -2229,6 +2273,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -2259,6 +2304,7 @@ enabled = false feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 080ac889de..e684bf6e5a 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -93,6 +93,8 @@ pub fn mcp_permission_prompt_is_auto_approved( pub struct McpConfig { /// Base URL for ChatGPT-hosted app MCP servers, copied from the root config. pub chatgpt_base_url: String, + /// Optional path override for the built-in apps MCP server. + pub apps_mcp_path_override: Option, /// Codex home directory used for MCP OAuth state and app-tool cache files. pub codex_home: PathBuf, /// Preferred credential store for MCP OAuth tokens. @@ -333,7 +335,10 @@ pub async fn collect_mcp_snapshot_from_manager( } pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { - codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url) + codex_apps_mcp_url_for_base_url( + &config.chatgpt_base_url, + config.apps_mcp_path_override.as_deref(), + ) } /// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. @@ -376,15 +381,19 @@ fn normalize_codex_apps_base_url(base_url: &str) -> String { base_url } -fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String { +fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Option<&str>) -> String { let base_url = normalize_codex_apps_base_url(base_url); - if base_url.contains("/backend-api") { - format!("{base_url}/wham/apps") + let (base_url, default_path) = if base_url.contains("/backend-api") { + (base_url, "wham/apps") } else if base_url.contains("/api/codex") { - format!("{base_url}/apps") + (base_url, "apps") } else { - format!("{base_url}/api/codex/apps") - } + (format!("{base_url}/api/codex"), "apps") + }; + let path = apps_mcp_path_override + .unwrap_or(default_path) + .trim_start_matches('/'); + format!("{base_url}/{path}") } fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig { diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 8c977c63ce..ef9fc9bfa4 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; fn test_mcp_config(codex_home: PathBuf) -> McpConfig { McpConfig { chatgpt_base_url: "https://chatgpt.com".to_string(), + apps_mcp_path_override: None, codex_home, mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), mcp_oauth_callback_port: None, @@ -109,19 +110,31 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { #[test] fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() { assert_eq!( - codex_apps_mcp_url_for_base_url("https://chatgpt.com/backend-api"), + codex_apps_mcp_url_for_base_url( + "https://chatgpt.com/backend-api", + /*apps_mcp_path_override*/ None, + ), "https://chatgpt.com/backend-api/wham/apps" ); assert_eq!( - codex_apps_mcp_url_for_base_url("https://chat.openai.com"), + codex_apps_mcp_url_for_base_url( + "https://chat.openai.com", + /*apps_mcp_path_override*/ None, + ), "https://chat.openai.com/backend-api/wham/apps" ); assert_eq!( - codex_apps_mcp_url_for_base_url("http://localhost:8080/api/codex"), + codex_apps_mcp_url_for_base_url( + "http://localhost:8080/api/codex", + /*apps_mcp_path_override*/ None, + ), "http://localhost:8080/api/codex/apps" ); assert_eq!( - codex_apps_mcp_url_for_base_url("http://localhost:8080"), + codex_apps_mcp_url_for_base_url( + "http://localhost:8080", + /*apps_mcp_path_override*/ None, + ), "http://localhost:8080/api/codex/apps" ); } @@ -158,6 +171,25 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() { assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); } +#[test] +fn codex_apps_server_config_uses_configured_apps_mcp_path_override() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + config.apps_mcp_path_override = Some("/custom/mcp".to_string()); + config.apps_enabled = true; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should be present when apps is enabled"); + let url = match &server.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => url, + _ => panic!("expected streamable http transport for codex apps"), + }; + + assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp"); +} + #[tokio::test] async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/collaboration-mode-templates/templates/default.md b/codex-rs/collaboration-mode-templates/templates/default.md index ff00857c6d..715982c396 100644 --- a/codex-rs/collaboration-mode-templates/templates/default.md +++ b/codex-rs/collaboration-mode-templates/templates/default.md @@ -6,6 +6,6 @@ Your active mode changes only when new developer instructions with a different ` ## request_user_input availability -{{REQUEST_USER_INPUT_AVAILABILITY}} +Use the `request_user_input` tool only when it is listed in the available tools for this turn. -{{ASKING_QUESTIONS_GUIDANCE}} +In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index a91ae892f5..59d1cde9ea 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -89,6 +89,7 @@ pub struct ConfigRequirements { pub feature_requirements: Option>, pub managed_hooks: Option>, pub mcp_servers: Option>>, + pub plugins: Option>>, pub exec_policy: Option>, pub enforce_residency: ConstrainedWithSource>, /// Managed network constraints derived from requirements. @@ -121,6 +122,7 @@ impl Default for ConfigRequirements { feature_requirements: None, managed_hooks: None, mcp_servers: None, + plugins: None, exec_policy: None, enforce_residency: ConstrainedWithSource::new( Constrained::allow_any(/*initial_value*/ None), @@ -151,6 +153,17 @@ pub struct McpServerRequirement { pub identity: McpServerIdentity, } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginRequirementsToml { + pub mcp_servers: Option>, +} + +impl PluginRequirementsToml { + pub fn is_empty(&self) -> bool { + self.mcp_servers.as_ref().is_none_or(BTreeMap::is_empty) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct NetworkDomainPermissionsToml { #[serde(flatten)] @@ -633,6 +646,7 @@ pub struct ConfigRequirementsToml { pub feature_requirements: Option, pub hooks: Option, pub mcp_servers: Option>, + pub plugins: Option>, pub apps: Option, pub rules: Option, pub enforce_residency: Option, @@ -679,6 +693,7 @@ pub struct ConfigRequirementsWithSources { pub feature_requirements: Option>, pub hooks: Option>, pub mcp_servers: Option>>, + pub plugins: Option>>, pub apps: Option>, pub rules: Option>, pub enforce_residency: Option>, @@ -714,6 +729,7 @@ impl ConfigRequirementsWithSources { feature_requirements: _, hooks: _, mcp_servers: _, + plugins: _, apps: _, rules: _, enforce_residency: _, @@ -742,6 +758,7 @@ impl ConfigRequirementsWithSources { feature_requirements, hooks, mcp_servers, + plugins, rules, enforce_residency, network, @@ -768,6 +785,7 @@ impl ConfigRequirementsWithSources { feature_requirements, hooks, mcp_servers, + plugins, apps, rules, enforce_residency, @@ -784,6 +802,7 @@ impl ConfigRequirementsWithSources { feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), + plugins: plugins.map(|sourced| sourced.value), apps: apps.map(|sourced| sourced.value), rules: rules.map(|sourced| sourced.value), enforce_residency: enforce_residency.map(|sourced| sourced.value), @@ -872,6 +891,10 @@ impl ConfigRequirementsToml { .as_ref() .is_none_or(ManagedHooksRequirementsToml::is_empty) && self.mcp_servers.is_none() + && self + .plugins + .as_ref() + .is_none_or(|plugins| plugins.values().all(PluginRequirementsToml::is_empty)) && self .apps .as_ref() @@ -899,6 +922,7 @@ impl TryFrom for ConfigRequirements { feature_requirements, hooks, mcp_servers, + plugins, apps: _apps, rules, enforce_residency, @@ -1133,6 +1157,7 @@ impl TryFrom for ConfigRequirements { feature_requirements, managed_hooks, mcp_servers, + plugins, exec_policy, enforce_residency, network, @@ -1204,6 +1229,7 @@ mod tests { feature_requirements, hooks, mcp_servers, + plugins, apps, rules, enforce_residency, @@ -1224,6 +1250,7 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), + plugins: plugins.map(|value| Sourced::new(value, RequirementSource::Unknown)), apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)), rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), enforce_residency: enforce_residency @@ -1269,6 +1296,7 @@ mod tests { feature_requirements: Some(feature_requirements.clone()), hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: Some(enforce_residency), @@ -1301,6 +1329,7 @@ mod tests { )), hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), @@ -1339,6 +1368,7 @@ mod tests { feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1385,6 +1415,7 @@ mod tests { feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -2700,6 +2731,55 @@ command = "python3 /enterprise/hooks/pre.py" Ok(()) } + #[test] + fn deserialize_plugin_mcp_server_requirements() -> Result<()> { + let toml_str = r#" + [plugins."sample@test".mcp_servers.sample.identity] + command = "sample-mcp" + + [plugins."remote@test".mcp_servers.remote.identity] + url = "https://example.com/mcp" + "#; + let requirements: ConfigRequirements = + with_unknown_source(from_str(toml_str)?).try_into()?; + + assert_eq!( + requirements.plugins, + Some(Sourced::new( + BTreeMap::from([ + ( + "remote@test".to_string(), + PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "remote".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: "https://example.com/mcp".to_string(), + }, + }, + )])), + }, + ), + ( + "sample@test".to_string(), + PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "sample".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "sample-mcp".to_string(), + }, + }, + )])), + }, + ), + ]), + RequirementSource::Unknown, + )) + ); + Ok(()) + } + #[test] fn deserialize_exec_policy_requirements() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 291c3609a5..3ad16cc346 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -48,6 +48,7 @@ pub use config_requirements::NetworkDomainPermissionsToml; pub use config_requirements::NetworkRequirementsToml; pub use config_requirements::NetworkUnixSocketPermissionToml; pub use config_requirements::NetworkUnixSocketPermissionsToml; +pub use config_requirements::PluginRequirementsToml; pub use config_requirements::RemoteSandboxConfigToml; pub use config_requirements::RequirementSource; pub use config_requirements::ResidencyRequirement; diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index 91aa50addb..715822fbfe 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -34,6 +34,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::AppsMcpPathOverride { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } validation .properties .insert(feature.key.to_string(), schema_gen.subschema_for::()); diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index b2d37b17bc..c9ec732a02 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -717,6 +717,50 @@ pub use crate::skills_config::SkillsConfig; pub struct PluginConfig { #[serde(default = "default_enabled")] pub enabled: bool, + + /// Per-MCP-server policy overlays for MCP servers contributed by this plugin. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcp_servers: HashMap, +} + +/// Policy settings for a plugin-provided MCP server. +/// +/// This intentionally excludes transport settings: plugin manifests own how the +/// MCP server is launched, while user config owns enablement and tool policy. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PluginMcpServerConfig { + /// When `false`, Codex skips initializing this plugin MCP server. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Approval mode for tools in this server unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Explicit allow-list of tools exposed from this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + + /// Explicit deny-list of tools. These tools are removed after applying `enabled_tools`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, + + /// Per-tool approval settings keyed by tool name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tools: HashMap, +} + +impl Default for PluginMcpServerConfig { + fn default() -> Self { + Self { + enabled: true, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + tools: HashMap::new(), + } + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] diff --git a/codex-rs/core-api/BUILD.bazel b/codex-rs/core-api/BUILD.bazel new file mode 100644 index 0000000000..646452cdc6 --- /dev/null +++ b/codex-rs/core-api/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "core-api", + crate_name = "codex_core_api", +) diff --git a/codex-rs/core-api/Cargo.toml b/codex-rs/core-api/Cargo.toml new file mode 100644 index 0000000000..5f0e24ea72 --- /dev/null +++ b/codex-rs/core-api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-core-api" +version.workspace = true + +[lib] +doctest = false +name = "codex_core_api" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-arg0 = { workspace = true } +codex-analytics = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-exec-server = { workspace = true } +codex-features = { workspace = true } +codex-login = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-models-manager = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs new file mode 100644 index 0000000000..00f1abb247 --- /dev/null +++ b/codex-rs/core-api/src/lib.rs @@ -0,0 +1,71 @@ +//! Public facade for thread management APIs built on `codex-core`. + +#![deny(private_bounds, private_interfaces, unreachable_pub)] + +pub use codex_analytics::AnalyticsEventsClient; +pub use codex_arg0::Arg0DispatchPaths; +pub use codex_arg0::arg0_dispatch_or_else; +pub use codex_config::ConfigLayerStack; +pub use codex_config::config_toml::ProjectConfig; +pub use codex_config::config_toml::RealtimeAudioConfig; +pub use codex_config::config_toml::RealtimeConfig; +pub use codex_config::types::AuthCredentialsStoreMode; +pub use codex_config::types::History; +pub use codex_config::types::MemoriesConfig; +pub use codex_config::types::ModelAvailabilityNuxConfig; +pub use codex_config::types::Notice; +pub use codex_config::types::OAuthCredentialsStoreMode; +pub use codex_config::types::OtelConfig; +pub use codex_config::types::ToolSuggestConfig; +pub use codex_config::types::TuiKeymap; +pub use codex_config::types::TuiNotificationSettings; +pub use codex_config::types::UriBasedFileOpener; +pub use codex_core::CodexThread; +pub use codex_core::ForkSnapshot; +pub use codex_core::McpManager; +pub use codex_core::NewThread; +pub use codex_core::StartThreadOptions; +pub use codex_core::ThreadManager; +pub use codex_core::ThreadShutdownReport; +pub use codex_core::config::Config; +pub use codex_core::config::Constrained; +pub use codex_core::config::GhostSnapshotConfig; +pub use codex_core::config::MultiAgentV2Config; +pub use codex_core::config::Permissions; +pub use codex_core::config::TerminalResizeReflowConfig; +pub use codex_core::config::ThreadStoreConfig; +pub use codex_core::config::find_codex_home; +pub use codex_core::plugins::PluginsManager; +pub use codex_core::skills::SkillsManager; +pub use codex_core::thread_store_from_config; +pub use codex_exec_server::EnvironmentManager; +pub use codex_exec_server::EnvironmentManagerArgs; +pub use codex_exec_server::ExecServerRuntimePaths; +pub use codex_features::Feature; +pub use codex_login::AuthManager; +pub use codex_login::default_client::set_default_originator; +pub use codex_model_provider_info::OPENAI_PROVIDER_ID; +pub use codex_model_provider_info::built_in_model_providers; +pub use codex_models_manager::manager::RefreshStrategy; +pub use codex_models_manager::manager::SharedModelsManager; +pub use codex_protocol::ThreadId; +pub use codex_protocol::config_types::AltScreenMode; +pub use codex_protocol::config_types::ApprovalsReviewer; +pub use codex_protocol::config_types::CollaborationModeMask; +pub use codex_protocol::config_types::ShellEnvironmentPolicy; +pub use codex_protocol::config_types::WebSearchMode; +pub use codex_protocol::dynamic_tools::DynamicToolSpec; +pub use codex_protocol::error::Result as CodexResult; +pub use codex_protocol::models::PermissionProfile; +pub use codex_protocol::openai_models::ModelPreset; +pub use codex_protocol::protocol::AskForApproval; +pub use codex_protocol::protocol::EventMsg; +pub use codex_protocol::protocol::InitialHistory; +pub use codex_protocol::protocol::McpServerRefreshConfig; +pub use codex_protocol::protocol::Op; +pub use codex_protocol::protocol::SessionConfiguredEvent; +pub use codex_protocol::protocol::SessionSource; +pub use codex_protocol::protocol::TurnEnvironmentSelection; +pub use codex_protocol::protocol::W3cTraceContext; +pub use codex_protocol::user_input::UserInput; +pub use codex_utils_absolute_path::AbsolutePathBuf; diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 45ce391073..82cb54c7fe 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -14,3 +14,19 @@ pub mod toggles; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled"; + +pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ + "github@openai-curated", + "notion@openai-curated", + "slack@openai-curated", + "gmail@openai-curated", + "google-calendar@openai-curated", + "google-drive@openai-curated", + "teams@openai-curated", + "sharepoint@openai-curated", + "outlook-email@openai-curated", + "outlook-calendar@openai-curated", + "linear@openai-curated", + "figma@openai-curated", + "computer-use@openai-bundled", +]; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index a2b3c8daa6..6dec8a2e45 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -12,6 +12,7 @@ use codex_config::ConfigLayerStack; use codex_config::HooksFile; use codex_config::types::McpServerConfig; use codex_config::types::PluginConfig; +use codex_config::types::PluginMcpServerConfig; use codex_core_skills::SkillMetadata; use codex_core_skills::config_rules::SkillConfigRules; use codex_core_skills::config_rules::resolve_disabled_skill_paths; @@ -111,6 +112,7 @@ pub async fn load_plugins_from_layer_stack( extra_plugins: HashMap, store: &PluginStore, restriction_product: Option, + plugin_hooks_enabled: bool, ) -> PluginLoadOutcome { let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); let mut configured_plugins = configured_plugins_from_stack(config_layer_stack); @@ -127,6 +129,7 @@ pub async fn load_plugins_from_layer_stack( store, restriction_product, &skill_config_rules, + plugin_hooks_enabled, ) .await; for name in loaded_plugin.mcp_servers.keys() { @@ -175,6 +178,7 @@ pub fn remote_installed_plugins_to_config( plugin_id.as_key(), PluginConfig { enabled: plugin.enabled, + mcp_servers: HashMap::new(), }, )) }) @@ -495,6 +499,7 @@ async fn load_plugin( store: &PluginStore, restriction_product: Option, skill_config_rules: &SkillConfigRules, + plugin_hooks_enabled: bool, ) -> LoadedPlugin { let plugin_id = PluginId::parse(&config_name); let active_plugin_root = plugin_id @@ -575,7 +580,10 @@ async fn load_plugin( let mut mcp_servers = HashMap::new(); 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).await; - for (name, config) in plugin_mcp.mcp_servers { + for (name, mut config) in plugin_mcp.mcp_servers { + if let Some(policy) = plugin.mcp_servers.get(&name) { + apply_plugin_mcp_server_policy(&mut config, policy); + } if mcp_servers.insert(name.clone(), config).is_some() { warn!( plugin = %plugin_root.display(), @@ -588,17 +596,38 @@ async fn load_plugin( } loaded_plugin.mcp_servers = mcp_servers; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; - let (hook_sources, hook_load_warnings) = load_plugin_hooks( - &plugin_root, - &loaded_plugin_id, - &store.plugin_data_root(&loaded_plugin_id), - manifest_paths, - ); - loaded_plugin.hook_sources = hook_sources; - loaded_plugin.hook_load_warnings = hook_load_warnings; + if plugin_hooks_enabled { + let (hook_sources, hook_load_warnings) = load_plugin_hooks( + &plugin_root, + &loaded_plugin_id, + &store.plugin_data_root(&loaded_plugin_id), + manifest_paths, + ); + loaded_plugin.hook_sources = hook_sources; + loaded_plugin.hook_load_warnings = hook_load_warnings; + } loaded_plugin } +fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginMcpServerConfig) { + config.enabled = policy.enabled; + if let Some(approval_mode) = policy.default_tools_approval_mode { + config.default_tools_approval_mode = Some(approval_mode); + } + if let Some(enabled_tools) = &policy.enabled_tools { + config.enabled_tools = Some(enabled_tools.clone()); + } + if let Some(disabled_tools) = &policy.disabled_tools { + config.disabled_tools = Some(disabled_tools.clone()); + } + for (tool_name, tool_policy) in &policy.tools { + let tool_config = config.tools.entry(tool_name.clone()).or_default(); + if let Some(approval_mode) = tool_policy.approval_mode { + tool_config.approval_mode = Some(approval_mode); + } + } +} + #[derive(Debug, Clone)] pub struct ResolvedPluginSkills { pub skills: Vec, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 032313ca1f..e6256691f5 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -13,10 +13,8 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; use std::fs; -use std::path::Path; use std::path::PathBuf; use std::time::Duration; -use tracing::warn; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; @@ -476,27 +474,6 @@ async fn fetch_remote_plugin_detail_with_download_url_option( .await } -async fn fetch_remote_plugin_detail_by_id( - config: &RemotePluginServiceConfig, - auth: &CodexAuth, - plugin_id: &str, -) -> Result { - let plugin = fetch_plugin_detail( - config, auth, plugin_id, /*include_download_urls*/ false, - ) - .await?; - let scope = plugin.scope; - build_remote_plugin_detail( - config, - auth, - scope, - scope.marketplace_name().to_string(), - plugin_id, - plugin, - ) - .await -} - async fn build_remote_plugin_detail( config: &RemotePluginServiceConfig, auth: &CodexAuth, @@ -589,6 +566,12 @@ pub async fn uninstall_remote_plugin( plugin_id: &str, ) -> Result<(), RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; + let plugin = fetch_plugin_detail( + config, auth, plugin_id, /*include_download_urls*/ false, + ) + .await?; + let marketplace_name = plugin.scope.marketplace_name().to_string(); + let plugin_name = plugin.name; let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/plugins/{plugin_id}/uninstall"); @@ -609,19 +592,9 @@ pub async fn uninstall_remote_plugin( }); } - let remote_detail = match fetch_remote_plugin_detail_by_id(config, auth, plugin_id).await { - Ok(remote_detail) => Some(remote_detail), - Err(err) => { - warn!( - plugin_id, - "failed to read remote plugin details after uninstall; skipping named cache removal: {err}" - ); - None - } - }; let legacy_plugin_id = plugin_id.to_string(); tokio::task::spawn_blocking(move || { - remove_remote_plugin_cache(codex_home, remote_detail, legacy_plugin_id) + remove_remote_plugin_cache(codex_home, marketplace_name, plugin_name, legacy_plugin_id) }) .await .map_err(|err| { @@ -636,65 +609,46 @@ pub async fn uninstall_remote_plugin( fn remove_remote_plugin_cache( codex_home: PathBuf, - remote_detail: Option, + marketplace_name: String, + plugin_name: String, legacy_plugin_id: String, ) -> Result<(), String> { - if let Some(remote_detail) = remote_detail { - let marketplace_name = remote_detail.marketplace_name; - let plugin_name = remote_detail.summary.name; - let store = PluginStore::try_new(codex_home.clone()) - .map_err(|err| format!("failed to resolve remote plugin cache root: {err}"))?; - let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone()).map_err( - |err| { - format!( - "invalid remote plugin cache id for `{plugin_name}` in `{marketplace_name}`: {err}" - ) - }, - )?; - let plugin_cache_root = store.plugin_base_root(&plugin_id); - store.uninstall(&plugin_id).map_err(|err| { + let store = PluginStore::try_new(codex_home.clone()) + .map_err(|err| format!("failed to resolve remote plugin cache root: {err}"))?; + let plugin_id = + PluginId::new(plugin_name.clone(), marketplace_name.clone()).map_err(|err| { format!( - "failed to remove remote plugin cache entry {}: {err}", - plugin_cache_root.display() + "invalid remote plugin cache id for `{plugin_name}` in `{marketplace_name}`: {err}" ) })?; - - let legacy_remote_plugin_cache_root = codex_home - .join(PLUGINS_CACHE_DIR) - .join(marketplace_name) - .join(legacy_plugin_id); - if legacy_remote_plugin_cache_root != plugin_cache_root.as_path() { - remove_path_if_exists(&legacy_remote_plugin_cache_root)?; - } - return Ok(()); - } - - for scope in RemotePluginScope::all() { - let legacy_remote_plugin_cache_root = codex_home - .join(PLUGINS_CACHE_DIR) - .join(scope.marketplace_name()) - .join(&legacy_plugin_id); - remove_path_if_exists(&legacy_remote_plugin_cache_root)?; - } - Ok(()) -} - -fn remove_path_if_exists(path: &Path) -> Result<(), String> { - if !path.exists() { - return Ok(()); - } - - let result = if path.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - }; - result.map_err(|err| { + let plugin_cache_root = store.plugin_base_root(&plugin_id); + store.uninstall(&plugin_id).map_err(|err| { format!( "failed to remove remote plugin cache entry {}: {err}", - path.display() + plugin_cache_root.display() ) - }) + })?; + + let legacy_remote_plugin_cache_root = codex_home + .join(PLUGINS_CACHE_DIR) + .join(marketplace_name) + .join(legacy_plugin_id); + if legacy_remote_plugin_cache_root != plugin_cache_root.as_path() + && legacy_remote_plugin_cache_root.exists() + { + let result = if legacy_remote_plugin_cache_root.is_dir() { + fs::remove_dir_all(&legacy_remote_plugin_cache_root) + } else { + fs::remove_file(&legacy_remote_plugin_cache_root) + }; + result.map_err(|err| { + format!( + "failed to remove remote plugin cache entry {}: {err}", + legacy_remote_plugin_cache_root.display() + ) + })?; + } + Ok(()) } fn build_remote_plugin_summary( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a17426d508..c8640c514b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -218,6 +218,18 @@ }, "type": "object" }, + "AppsMcpPathOverrideConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "type": "object" + }, "AskForApproval": { "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", "oneOf": [ @@ -355,6 +367,9 @@ "apps": { "type": "boolean" }, + "apps_mcp_path_override": { + "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" + }, "browser_use": { "type": "boolean" }, @@ -705,6 +720,16 @@ }, "type": "object" }, + "FeatureToml_for_AppsMcpPathOverrideConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/AppsMcpPathOverrideConfigToml" + } + ] + }, "FeatureToml_for_MultiAgentV2ConfigToml": { "anyOf": [ { @@ -1735,6 +1760,54 @@ "enabled": { "default": true, "type": "boolean" + }, + "mcp_servers": { + "additionalProperties": { + "$ref": "#/definitions/PluginMcpServerConfig" + }, + "description": "Per-MCP-server policy overlays for MCP servers contributed by this plugin.", + "type": "object" + } + }, + "type": "object" + }, + "PluginMcpServerConfig": { + "additionalProperties": false, + "description": "Policy settings for a plugin-provided MCP server.\n\nThis intentionally excludes transport settings: plugin manifests own how the MCP server is launched, while user config owns enablement and tool policy.", + "properties": { + "default_tools_approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for tools in this server unless a tool override exists." + }, + "disabled_tools": { + "description": "Explicit deny-list of tools. These tools are removed after applying `enabled_tools`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "enabled": { + "default": true, + "description": "When `false`, Codex skips initializing this plugin MCP server.", + "type": "boolean" + }, + "enabled_tools": { + "description": "Explicit allow-list of tools exposed from this server.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/McpServerToolConfig" + }, + "description": "Per-tool approval settings keyed by tool name.", + "type": "object" } }, "type": "object" @@ -3297,6 +3370,9 @@ "apps": { "type": "boolean" }, + "apps_mcp_path_override": { + "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" + }, "browser_use": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index e1c796ef32..30c7c4cccb 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -15,6 +15,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseInputItem; @@ -48,6 +49,7 @@ pub struct ThreadConfigSnapshot { pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, + pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, pub ephemeral: bool, pub reasoning_effort: Option, @@ -75,6 +77,7 @@ pub struct CodexThreadTurnContextOverrides { pub approvals_reviewer: Option, pub sandbox_policy: Option, pub permission_profile: Option, + pub active_permission_profile: Option, pub windows_sandbox_level: Option, pub model: Option, pub effort: Option>, @@ -225,6 +228,7 @@ impl CodexThread { approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level, model, effort, @@ -249,6 +253,7 @@ impl CodexThread { approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level, collaboration_mode: Some(collaboration_mode), reasoning_summary: summary, diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 06d6f91294..d9a246373a 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -780,6 +780,7 @@ allowed_approval_policies = ["on-request"] feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -836,6 +837,7 @@ allowed_approval_policies = ["on-request"] feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -1043,6 +1045,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 097e93baac..6c39f1f1e9 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -60,6 +60,8 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; @@ -907,6 +909,14 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted ); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some("workspace") + ); Ok(()) } @@ -928,6 +938,7 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: .await?; assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!(config.permissions.active_permission_profile(), None); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::DangerFullAccess @@ -1267,6 +1278,14 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl .await?; let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(":workspace") + ); assert!( policy.can_write_path_with_cwd(cwd.path(), cwd.path()), "expected :workspace to allow writing the project root, policy: {policy:?}" @@ -1278,6 +1297,86 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl Ok(()) } +#[tokio::test] +async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; + let extra_root = extra_root.path().abs(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":read-only".to_string()), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + additional_writable_roots: vec![extra_root.to_path_buf()], + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), + "expected additional writable root to modify :read-only, policy: {policy:?}" + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some( + ActivePermissionProfile::new(":read-only").with_modifications(vec![ + ActivePermissionProfileModification::AdditionalWritableRoot { path: extra_root }, + ]) + ) + ); + Ok(()) +} + +#[tokio::test] +async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_settings() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":workspace".to_string()), + sandbox_workspace_write: Some(SandboxWorkspaceWrite { + writable_roots: vec![extra_root.path().abs()], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Restricted + ); + assert!( + !policy.entries.iter().any(|entry| matches!( + &entry.path, + FileSystemPath::Path { path } if path.as_path() == extra_root.path() + )), + "explicit :workspace should not inherit sandbox_workspace_write roots as concrete grants, \ + policy: {policy:?}" + ); + Ok(()) +} + #[tokio::test] async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -1303,6 +1402,18 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: .await?; let policy = config.permissions.file_system_sandbox_policy(); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(if cfg!(target_os = "windows") { + ":read-only" + } else { + ":workspace" + }) + ); if cfg!(target_os = "windows") { assert!( !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), @@ -1367,6 +1478,12 @@ async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_se config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled ); + assert_eq!( + config.permissions.active_permission_profile(), + None, + "implicit :workspace cannot be faithfully re-selected when it includes \ + legacy sandbox_workspace_write settings" + ); match config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, @@ -1484,6 +1601,14 @@ async fn default_permissions_can_select_builtin_no_sandbox_profile() -> std::io: config.permissions.permission_profile(), PermissionProfile::Disabled ); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|active| active.id.as_str()), + Some(":danger-no-sandbox") + ); Ok(()) } @@ -2404,6 +2529,258 @@ fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() { ); } +#[test] +fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { + const MATCHED_SERVER: &str = "matched-should-allow"; + const MISMATCHED_SERVER: &str = "mismatched-should-disable"; + const UNLISTED_SERVER: &str = "unlisted-should-disable"; + const GOOD_CMD: &str = "good-cmd"; + + let mut servers = HashMap::from([ + (MATCHED_SERVER.to_string(), stdio_mcp(GOOD_CMD)), + (MISMATCHED_SERVER.to_string(), stdio_mcp("bad-cmd")), + ( + UNLISTED_SERVER.to_string(), + http_mcp("https://example.com/mcp"), + ), + ]); + let source = RequirementSource::CloudRequirements; + let requirements = Sourced::new( + BTreeMap::from([( + "sample@test".to_string(), + codex_config::PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([ + ( + MATCHED_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: GOOD_CMD.to_string(), + }, + }, + ), + ( + MISMATCHED_SERVER.to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: GOOD_CMD.to_string(), + }, + }, + ), + ])), + }, + )]), + source.clone(), + ); + + filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements)); + + let reason = Some(McpServerDisabledReason::Requirements { source }); + assert_eq!( + servers + .iter() + .map(|(name, server)| ( + name.clone(), + (server.enabled, server.disabled_reason.clone()) + )) + .collect::)>>(), + HashMap::from([ + (MATCHED_SERVER.to_string(), (true, None)), + (MISMATCHED_SERVER.to_string(), (false, reason.clone())), + (UNLISTED_SERVER.to_string(), (false, reason)), + ]) + ); +} + +#[test] +fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { + let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]); + let source = RequirementSource::CloudRequirements; + let requirements = Sourced::new( + BTreeMap::from([( + "other@test".to_string(), + codex_config::PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "server-a".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "cmd-a".to_string(), + }, + }, + )])), + }, + )]), + source.clone(), + ); + + filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements)); + + assert_eq!( + servers + .iter() + .map(|(name, server)| ( + name.clone(), + (server.enabled, server.disabled_reason.clone()) + )) + .collect::)>>(), + HashMap::from([( + "server-a".to_string(), + ( + false, + Some(McpServerDisabledReason::Requirements { source }) + ) + )]) + ); +} + +#[tokio::test] +async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + }, + "unlisted": { + "type": "http", + "url": "https://unlisted.example/mcp" + } + } +}"#, + )?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[plugins."sample@test"] +enabled = true +"#, + )?; + + let requirements = codex_config::ConfigRequirementsToml { + plugins: Some(BTreeMap::from([( + "sample@test".to_string(), + codex_config::PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "sample".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Url { + url: "https://sample.example/mcp".to_string(), + }, + }, + )])), + }, + )])), + ..Default::default() + }; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get("sample") + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some((true, None)) + ); + assert_eq!( + mcp_config + .configured_mcp_servers + .get("unlisted") + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some(( + false, + Some(McpServerDisabledReason::Requirements { + source: RequirementSource::CloudRequirements, + }) + )) + ); + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_empty_mcp_requirements_disable_plugin_mcps() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + )?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[plugins."sample@test"] +enabled = true +"#, + )?; + + let requirements = codex_config::ConfigRequirementsToml { + mcp_servers: Some(BTreeMap::new()), + ..Default::default() + }; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get("sample") + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some(( + false, + Some(McpServerDisabledReason::Requirements { + source: RequirementSource::CloudRequirements, + }) + )) + ); + Ok(()) +} + #[tokio::test] async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -3241,8 +3618,13 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + config.apps_mcp_path_override = Some("/custom/mcp".to_string()); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.apps_enabled); + assert_eq!( + mcp_config.apps_mcp_path_override.as_deref(), + Some("/custom/mcp") + ); let _ = config.features.disable(Feature::Apps); let mcp_config = config.to_mcp_config(&plugins_manager).await; @@ -5894,6 +6276,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -5945,6 +6328,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + apps_mcp_path_override: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -6088,6 +6472,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6139,6 +6524,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + apps_mcp_path_override: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -6236,6 +6622,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6287,6 +6674,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + apps_mcp_path_override: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -6369,6 +6757,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -6420,6 +6809,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_verbosity: Some(Verbosity::High), personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + apps_mcp_path_override: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -6490,6 +6880,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -7058,6 +7449,32 @@ allow_login_shell = false Ok(()) } +#[tokio::test] +async fn config_loads_apps_mcp_path_override_from_feature_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" + +[features.apps_mcp_path_override] +path = "/custom/mcp" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.apps_mcp_path_override.as_deref(), + Some("/custom/mcp") + ); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -7175,6 +7592,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s feature_requirements: None, hooks: None, mcp_servers: None, + plugins: None, apps: None, rules: None, enforce_residency: None, @@ -7229,6 +7647,41 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( Ok(()) } +#[tokio::test] +async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), + ..Default::default() + }; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .harness_overrides(ConfigOverrides { + default_permissions: Some(":danger-no-sandbox".to_string()), + ..Default::default() + }) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + + assert_eq!( + config.permissions.permission_profile(), + PermissionProfile::read_only() + ); + assert_eq!(config.permissions.active_permission_profile(), None); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn permission_profile_override_preserves_split_write_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fe9a8e4334..c5965d389e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -18,6 +18,7 @@ use codex_config::FeatureRequirementsToml; use codex_config::LoaderOverrides; use codex_config::McpServerIdentity; use codex_config::McpServerRequirement; +use codex_config::PluginRequirementsToml; use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::Sourced; @@ -55,6 +56,7 @@ use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; +use codex_features::AppsMcpPathOverrideConfigToml; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -84,6 +86,8 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -103,6 +107,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::builtin_permission_profile; use crate::config::permissions::compile_permission_profile_selection; use crate::config::permissions::default_builtin_permission_profile_name; @@ -227,6 +232,9 @@ pub struct Permissions { /// Canonical effective runtime permissions after config requirements and /// runtime readable-root additions have been applied. pub permission_profile: Constrained, + /// Named or implicit built-in profile selected by config, rather than an + /// ad-hoc override. + pub active_permission_profile: Option, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -254,6 +262,11 @@ impl Permissions { self.permission_profile.get().clone() } + /// Named profile selected by config, if the current profile has one. + pub fn active_permission_profile(&self) -> Option { + self.active_permission_profile.clone() + } + /// Effective filesystem sandbox policy derived from the canonical profile. pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { self.permission_profile.get().file_system_sandbox_policy() @@ -312,6 +325,7 @@ impl Permissions { ); self.permission_profile.set(permission_profile)?; + self.active_permission_profile = None; Ok(()) } @@ -319,10 +333,24 @@ impl Permissions { pub fn set_permission_profile( &mut self, permission_profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.set_permission_profile_with_active_profile( + permission_profile, + /*active_permission_profile*/ None, + ) + } + + /// Replace permissions from the canonical profile and record the named + /// source profile, if one is known. + pub fn set_permission_profile_with_active_profile( + &mut self, + permission_profile: PermissionProfile, + active_permission_profile: Option, ) -> ConstraintResult<()> { self.permission_profile.can_set(&permission_profile)?; self.permission_profile.set(permission_profile)?; + self.active_permission_profile = active_permission_profile; Ok(()) } } @@ -647,6 +675,9 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Optional path override for the built-in apps MCP server. + pub apps_mcp_path_override: Option, + /// Machine-local realtime audio device preferences used by realtime voice. pub realtime_audio: RealtimeAudioConfig, @@ -971,12 +1002,31 @@ impl Config { ) -> McpConfig { let loaded_plugins = plugins_manager.plugins_for_config(self).await; let mut configured_mcp_servers = self.mcp_servers.get().clone(); - for (name, plugin_server) in loaded_plugins.effective_mcp_servers() { - configured_mcp_servers.entry(name).or_insert(plugin_server); + for plugin in loaded_plugins + .plugins() + .iter() + .filter(|plugin| plugin.is_active()) + { + let mut plugin_mcp_servers = plugin.mcp_servers.clone(); + filter_plugin_mcp_servers_by_requirements( + &plugin.config_name, + &mut plugin_mcp_servers, + self.config_layer_stack.requirements().plugins.as_ref(), + ); + for (name, plugin_server) in plugin_mcp_servers { + configured_mcp_servers.entry(name).or_insert(plugin_server); + } + } + if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref() + && mcp_requirements.value.is_empty() + { + // A present empty allowlist bans all MCPs, including plugin MCPs merged above. + filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), + apps_mcp_path_override: self.apps_mcp_path_override.clone(), codex_home: self.codex_home.to_path_buf(), mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode, mcp_oauth_callback_port: self.mcp_oauth_callback_port, @@ -1181,6 +1231,35 @@ fn filter_mcp_servers_by_requirements( } } +fn filter_plugin_mcp_servers_by_requirements( + plugin_config_name: &str, + mcp_servers: &mut HashMap, + plugin_requirements: Option<&Sourced>>, +) { + let Some(requirements) = plugin_requirements else { + return; + }; + let source = requirements.source.clone(); + let plugin_mcp_requirements = requirements + .value + .get(plugin_config_name) + .and_then(|plugin| plugin.mcp_servers.as_ref()); + + for (name, server) in mcp_servers.iter_mut() { + let allowed = plugin_mcp_requirements + .and_then(|mcp_requirements| mcp_requirements.get(name)) + .is_some_and(|requirement| mcp_server_matches_requirement(requirement, server)); + if allowed { + server.disabled_reason = None; + } else { + server.enabled = false; + server.disabled_reason = Some(McpServerDisabledReason::Requirements { + source: source.clone(), + }); + } + } +} + fn constrain_mcp_servers( mcp_servers: HashMap, mcp_requirements: Option<&Sourced>>, @@ -1201,7 +1280,7 @@ fn apply_requirement_constrained_value( configured_value: T, constrained_value: &mut ConstrainedWithSource, startup_warnings: &mut Vec, -) -> std::io::Result<()> +) -> std::io::Result where T: Clone + std::fmt::Debug + Send + Sync, { @@ -1226,9 +1305,10 @@ where ), ) })?; + return Ok(true); } - Ok(()) + Ok(false) } fn mcp_server_matches_requirement( @@ -1628,6 +1708,7 @@ pub struct ConfigOverrides { pub approvals_reviewer: Option, pub sandbox_mode: Option, pub permission_profile: Option, + pub default_permissions: Option, pub model_provider: Option, pub service_tier: Option>, pub config_profile: Option, @@ -1786,6 +1867,15 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA } } +fn apps_mcp_path_override_toml_config( + features: Option<&FeaturesToml>, +) -> Option<&AppsMcpPathOverrideConfigToml> { + match features?.apps_mcp_path_override.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, @@ -1862,6 +1952,7 @@ impl Config { feature_requirements, managed_hooks: _, mcp_servers, + plugins: _, exec_policy: _, enforce_residency, network: network_requirements, @@ -1882,6 +1973,7 @@ impl Config { approvals_reviewer: approvals_reviewer_override, sandbox_mode, permission_profile, + default_permissions: default_permissions_override, model_provider, service_tier: service_tier_override, config_profile: config_profile_key, @@ -1906,6 +1998,18 @@ impl Config { "`sandbox_mode` and `permission_profile` overrides cannot both be set", )); } + if sandbox_mode.is_some() && default_permissions_override.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`sandbox_mode` and `default_permissions` overrides cannot both be set", + )); + } + if permission_profile.is_some() && default_permissions_override.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`permission_profile` and `default_permissions` overrides cannot both be set", + )); + } let active_profile_name = config_profile_key .as_ref() @@ -1977,6 +2081,7 @@ impl Config { .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); + let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -1994,13 +2099,16 @@ impl Config { .permissions .as_ref() .is_some_and(|profiles| !profiles.is_empty()); + let default_permissions = default_permissions_override + .as_deref() + .or(cfg.default_permissions.as_deref()); validate_user_permission_profile_names(cfg.permissions.as_ref())?; if has_permission_profiles && !matches!( permission_config_syntax, Some(PermissionConfigSyntax::Legacy) ) - && cfg.default_permissions.is_none() + && default_permissions.is_none() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -2022,15 +2130,19 @@ impl Config { additional_writable_roots.push(memories_root); } - let profiles_are_active = matches!( - permission_config_syntax, - Some(PermissionConfigSyntax::Profiles) - ) || permission_config_syntax.is_none(); - let using_implicit_builtin_profile = permission_config_syntax.is_none(); + let profiles_are_active = default_permissions_override.is_some() + || matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ) + || permission_config_syntax.is_none(); + let using_implicit_builtin_profile = + permission_config_syntax.is_none() && default_permissions.is_none(); let ( configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + mut active_permission_profile, ) = if let Some(mut permission_profile) = permission_profile { let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); @@ -2041,7 +2153,7 @@ impl Config { // PermissionProfile carries the active network sandbox bit, not the configured // proxy/allowlist policy. Keep that config so active profiles can round-trip // without broadening network behavior. - let default_permissions = cfg.default_permissions.as_deref().unwrap_or_else(|| { + let default_permissions = default_permissions.unwrap_or_else(|| { default_builtin_permission_profile_name( &active_project, windows_sandbox_level, @@ -2076,11 +2188,17 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + None, ) } else if profiles_are_active { - let default_permissions = cfg.default_permissions.as_deref().unwrap_or_else(|| { + let default_permissions = default_permissions.unwrap_or_else(|| { default_builtin_permission_profile_name(&active_project, windows_sandbox_level) }); + let builtin_workspace_write_settings = if using_implicit_builtin_profile { + cfg.sandbox_workspace_write.as_ref() + } else { + None + }; let configured_network_proxy_config = network_proxy_config_for_profile_selection( cfg.permissions.as_ref(), default_permissions, @@ -2089,12 +2207,12 @@ impl Config { compile_permission_profile_selection( cfg.permissions.as_ref(), default_permissions, - cfg.sandbox_workspace_write.as_ref(), + builtin_workspace_write_settings, resolved_cwd.as_path(), &mut startup_warnings, )?; let mut permission_profile = if let Some(permission_profile) = - builtin_permission_profile(default_permissions, cfg.sandbox_workspace_write.as_ref()) + builtin_permission_profile(default_permissions, builtin_workspace_write_settings) { permission_profile } else { @@ -2125,11 +2243,51 @@ impl Config { &file_system_sandbox_policy, network_sandbox_policy, ); + } else if matches!(permission_profile, PermissionProfile::Managed { .. }) + && !requested_additional_writable_roots.is_empty() + { + file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( + resolved_cwd.as_path(), + &requested_additional_writable_roots, + ); + permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + ); } + let active_permission_profile = if using_implicit_builtin_profile + && default_permissions == BUILT_IN_WORKSPACE_PROFILE + && cfg.sandbox_workspace_write.is_some() + { + // The implicit built-in profile preserves legacy + // `[sandbox_workspace_write]` customizations, but explicitly + // selecting `:workspace` intentionally ignores those legacy + // settings. Do not advertise a re-selectable active profile + // when doing so would lose roots, network, or tmp settings. + None + } else { + let active_permission_profile = if !requested_additional_writable_roots.is_empty() + && matches!(permission_profile, PermissionProfile::Managed { .. }) + { + ActivePermissionProfile::new(default_permissions).with_modifications( + requested_additional_writable_roots + .iter() + .cloned() + .map(|path| { + ActivePermissionProfileModification::AdditionalWritableRoot { path } + }) + .collect(), + ) + } else { + ActivePermissionProfile::new(default_permissions) + }; + Some(active_permission_profile) + }; ( configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + active_permission_profile, ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2191,6 +2349,7 @@ impl Config { configured_network_proxy_config, permission_profile, file_system_sandbox_policy, + None, ) }; let approval_policy_was_explicit = approval_policy_override.is_some() @@ -2237,6 +2396,16 @@ impl Config { .unwrap_or(WebSearchMode::Cached); let web_search_config = resolve_web_search_config(&cfg, &config_profile); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile); + let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) { + let base = apps_mcp_path_override_toml_config(cfg.features.as_ref()); + let profile = apps_mcp_path_override_toml_config(config_profile.features.as_ref()); + profile + .and_then(|config| config.path.as_ref()) + .or_else(|| base.and_then(|config| config.path.as_ref())) + .cloned() + } else { + None + }; let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = @@ -2560,12 +2729,17 @@ impl Config { &mut constrained_approvals_reviewer, &mut startup_warnings, )?; - apply_requirement_constrained_value( + let permission_profile_was_constrained = apply_requirement_constrained_value( "permission_profile", permission_profile, &mut constrained_permission_profile, &mut startup_warnings, )?; + if permission_profile_was_constrained { + // The selected profile no longer describes the effective + // permissions after requirements forced a fallback. + active_permission_profile = None; + } apply_requirement_constrained_value( "web_search_mode", web_search_mode, @@ -2648,6 +2822,7 @@ impl Config { permissions: Permissions { approval_policy: constrained_approval_policy.value, permission_profile: constrained_permission_profile.value, + active_permission_profile, network, allow_login_shell, shell_environment_policy, @@ -2738,6 +2913,7 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + apps_mcp_path_override, realtime_audio: cfg .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 4725d3d632..e95eda02b5 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -13,6 +13,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; @@ -464,6 +465,18 @@ impl GuardianReviewSessionManager { } } + #[cfg(test)] + pub(crate) async fn send_trunk_event_raw_for_test(&self, event: Event) { + let trunk = self + .state + .lock() + .await + .trunk + .clone() + .expect("guardian trunk should exist"); + trunk.codex.session.send_event_raw(event).await; + } + async fn remove_trunk_if_current( &self, trunk: &Arc, @@ -711,8 +724,8 @@ async fn run_review_on_session( })), ) .await; - match submit_result { - Ok(Ok(_)) => {} + let child_turn_id = match submit_result { + Ok(Ok(child_turn_id)) => child_turn_id, Ok(Err(err)) => { return ( GuardianReviewSessionOutcome::SessionFailed(err.into()), @@ -721,11 +734,12 @@ async fn run_review_on_session( ); } Err(outcome) => return (outcome, false, analytics_result), - } + }; analytics_result.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review( review_session, + child_turn_id.as_str(), deadline, params.external_cancel.as_ref(), &mut analytics_result, @@ -770,6 +784,7 @@ async fn load_rollout_items_for_fork( async fn wait_for_guardian_review( review_session: &GuardianReviewSession, + expected_turn_id: &str, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, analytics_result: &mut GuardianReviewAnalyticsResult, @@ -781,7 +796,12 @@ async fn wait_for_guardian_review( loop { tokio::select! { _ = &mut timeout => { - let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); + let keep_review_session = interrupt_and_drain_turn( + &review_session.codex, + expected_turn_id, + ) + .await + .is_ok(); return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { @@ -791,11 +811,17 @@ async fn wait_for_guardian_review( std::future::pending::<()>().await; } } => { - let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); + let keep_review_session = interrupt_and_drain_turn( + &review_session.codex, + expected_turn_id, + ) + .await + .is_ok(); return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { + Ok(event) if !event_matches_turn(&event, expected_turn_id) => {} Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { analytics_result.time_to_first_token_ms = turn_complete @@ -837,6 +863,20 @@ async fn wait_for_guardian_review( } } +fn event_matches_turn(event: &Event, expected_turn_id: &str) -> bool { + if event.id != expected_turn_id { + return false; + } + + match &event.msg { + EventMsg::TurnComplete(turn_complete) => turn_complete.turn_id == expected_turn_id, + EventMsg::TurnAborted(turn_aborted) => { + turn_aborted.turn_id.as_deref() == Some(expected_turn_id) + } + _ => true, + } +} + pub(crate) fn build_guardian_review_session_config( parent_config: &Config, live_network_config: Option, @@ -944,16 +984,18 @@ async fn run_before_review_deadline_with_cancel( result } -async fn interrupt_and_drain_turn(codex: &Codex) -> anyhow::Result<()> { +async fn interrupt_and_drain_turn(codex: &Codex, expected_turn_id: &str) -> anyhow::Result<()> { let _ = codex.submit(Op::Interrupt).await; tokio::time::timeout(GUARDIAN_INTERRUPT_DRAIN_TIMEOUT, async { loop { let event = codex.next_event().await?; - if matches!( - event.msg, - EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_) - ) { + if event_matches_turn(&event, expected_turn_id) + && matches!( + event.msg, + EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_) + ) + { return Ok::<(), anyhow::Error>(()); } } @@ -967,6 +1009,114 @@ async fn interrupt_and_drain_turn(codex: &Codex) -> anyhow::Result<()> { #[cfg(test)] mod tests { use super::*; + use codex_protocol::protocol::AgentStatus; + use codex_protocol::protocol::ErrorEvent; + use codex_protocol::protocol::Submission; + use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::protocol::TurnAbortedEvent; + use codex_protocol::protocol::TurnCompleteEvent; + + async fn test_review_session() -> ( + GuardianReviewSession, + async_channel::Sender, + async_channel::Receiver, + ) { + let (session, _turn, _rx) = crate::session::tests::make_session_and_context_with_rx().await; + let (tx_sub, rx_sub) = async_channel::bounded(4); + let (tx_event, rx_event) = async_channel::unbounded(); + let (_agent_status_tx, agent_status) = + tokio::sync::watch::channel(AgentStatus::PendingInit); + let reuse_key = + GuardianReviewSessionReuseKey::from_spawn_config(session.get_config().await.as_ref()); + + ( + GuardianReviewSession { + codex: Codex { + tx_sub, + rx_event, + agent_status, + session, + session_loop_termination: crate::session::completed_session_loop_termination(), + }, + cancel_token: CancellationToken::new(), + reuse_key, + review_lock: Semaphore::new(/*permits*/ 1), + state: Mutex::new(GuardianReviewState { + prior_review_count: 0, + last_reviewed_transcript_cursor: None, + last_committed_fork_snapshot: None, + }), + }, + tx_event, + rx_sub, + ) + } + + fn turn_complete_event( + turn_id: &str, + last_agent_message: Option<&str>, + time_to_first_token_ms: Option, + ) -> Event { + Event { + id: turn_id.to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: last_agent_message.map(str::to_string), + completed_at: None, + duration_ms: None, + time_to_first_token_ms, + }), + } + } + + fn turn_aborted_event(turn_id: &str) -> Event { + Event { + id: turn_id.to_string(), + msg: EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, + }), + } + } + + async fn test_review_params() -> GuardianReviewSessionParams { + let (session, turn) = crate::session::tests::make_session_and_context().await; + let model = turn.model_info.slug.clone(); + let reasoning_effort = turn.reasoning_effort; + let reasoning_summary = turn.reasoning_summary; + let personality = turn.personality; + let cwd = turn.cwd.clone(); + let spawn_config = build_guardian_review_session_config( + turn.config.as_ref(), + /*live_network_config*/ None, + model.as_str(), + reasoning_effort, + ) + .expect("guardian config"); + + GuardianReviewSessionParams { + parent_session: Arc::new(session), + parent_turn: Arc::new(turn), + spawn_config, + request: GuardianApprovalRequest::Shell { + id: "shell-1".to_string(), + command: vec!["git".to_string(), "status".to_string()], + cwd, + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: Some("Inspect repo state.".to_string()), + }, + retry_reason: None, + schema: super::super::prompt::guardian_output_schema(), + model, + reasoning_effort, + reasoning_summary, + personality, + external_cancel: None, + } + } #[tokio::test] async fn guardian_review_session_config_change_invalidates_cached_session() { @@ -1175,4 +1325,244 @@ mod tests { } ); } + + #[tokio::test] + async fn run_review_on_reused_session_waits_for_submitted_turn() { + let (review_session, tx_event, rx_sub) = test_review_session().await; + { + let mut state = review_session.state.lock().await; + state.prior_review_count = 1; + state.last_reviewed_transcript_cursor = Some(GuardianTranscriptCursor { + parent_history_version: 0, + transcript_entry_count: 0, + }); + } + let params = test_review_params().await; + + let review = tokio::spawn(async move { + run_review_on_session( + &review_session, + ¶ms, + GuardianReviewSessionKind::TrunkReused, + tokio::time::Instant::now() + Duration::from_secs(1), + ) + .await + }); + let submission = rx_sub.recv().await.expect("guardian submission"); + tx_event + .send(turn_complete_event("prior-turn", Some("stale"), Some(9))) + .await + .expect("queue prior turn completion"); + tx_event + .send(turn_complete_event( + submission.id.as_str(), + Some("fresh"), + Some(42), + )) + .await + .expect("queue submitted turn completion"); + + let (outcome, keep_review_session, analytics_result) = + review.await.expect("review task should complete"); + let GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) = outcome else { + panic!("expected submitted turn completion"); + }; + assert_eq!(last_agent_message.as_deref(), Some("fresh")); + assert_eq!(analytics_result.time_to_first_token_ms, Some(42)); + assert!(keep_review_session); + } + + #[tokio::test] + async fn wait_for_guardian_review_ignores_prior_turn_completion() { + let (review_session, tx_event, _rx_sub) = test_review_session().await; + tx_event + .send(turn_complete_event("prior-turn", Some("stale"), Some(9))) + .await + .expect("queue prior turn completion"); + tx_event + .send(turn_complete_event("current-turn", Some("fresh"), Some(42))) + .await + .expect("queue current turn completion"); + + let mut analytics_result = GuardianReviewAnalyticsResult::without_session(); + let (outcome, keep_review_session, capture_token_usage) = wait_for_guardian_review( + &review_session, + "current-turn", + tokio::time::Instant::now() + Duration::from_secs(1), + /*external_cancel*/ None, + &mut analytics_result, + ) + .await; + + let GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) = outcome else { + panic!("expected current turn completion"); + }; + assert_eq!(last_agent_message.as_deref(), Some("fresh")); + assert_eq!(analytics_result.time_to_first_token_ms, Some(42)); + assert!(keep_review_session); + assert!(capture_token_usage); + } + + #[tokio::test] + async fn wait_for_guardian_review_ignores_prior_turn_errors() { + let (review_session, tx_event, _rx_sub) = test_review_session().await; + tx_event + .send(Event { + id: "prior-turn".to_string(), + msg: EventMsg::Error(ErrorEvent { + message: "stale guardian error".to_string(), + codex_error_info: None, + }), + }) + .await + .expect("queue prior turn error"); + tx_event + .send(turn_complete_event( + "current-turn", + /*last_agent_message*/ None, + Some(42), + )) + .await + .expect("queue current turn completion"); + + let mut analytics_result = GuardianReviewAnalyticsResult::without_session(); + let (outcome, keep_review_session, capture_token_usage) = wait_for_guardian_review( + &review_session, + "current-turn", + tokio::time::Instant::now() + Duration::from_secs(1), + /*external_cancel*/ None, + &mut analytics_result, + ) + .await; + + let GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) = outcome else { + panic!("expected current turn completion"); + }; + assert_eq!(last_agent_message, None); + assert_eq!(analytics_result.time_to_first_token_ms, Some(42)); + assert!(keep_review_session); + assert!(capture_token_usage); + } + + #[tokio::test] + async fn wait_for_guardian_review_ignores_prior_turn_aborts() { + let (review_session, tx_event, _rx_sub) = test_review_session().await; + tx_event + .send(turn_aborted_event("prior-turn")) + .await + .expect("queue prior turn abort"); + tx_event + .send(turn_complete_event("current-turn", Some("fresh"), Some(42))) + .await + .expect("queue current turn completion"); + + let mut analytics_result = GuardianReviewAnalyticsResult::without_session(); + let (outcome, keep_review_session, capture_token_usage) = wait_for_guardian_review( + &review_session, + "current-turn", + tokio::time::Instant::now() + Duration::from_secs(1), + /*external_cancel*/ None, + &mut analytics_result, + ) + .await; + + let GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) = outcome else { + panic!("expected current turn completion"); + }; + assert_eq!(last_agent_message.as_deref(), Some("fresh")); + assert_eq!(analytics_result.time_to_first_token_ms, Some(42)); + assert!(keep_review_session); + assert!(capture_token_usage); + } + + #[tokio::test] + async fn wait_for_guardian_review_timeout_drains_expected_turn_after_stale_terminal_event() { + let (review_session, tx_event, rx_sub) = test_review_session().await; + tx_event + .send(turn_complete_event("prior-turn", Some("stale"), Some(9))) + .await + .expect("queue prior turn completion"); + let tx_interrupt_event = tx_event.clone(); + let interrupt_response = tokio::spawn(async move { + let submission = rx_sub.recv().await.expect("interrupt submission"); + assert!(matches!(submission.op, Op::Interrupt)); + tx_interrupt_event + .send(turn_aborted_event("current-turn")) + .await + .expect("queue current turn abort"); + }); + + let mut analytics_result = GuardianReviewAnalyticsResult::without_session(); + let (outcome, keep_review_session, capture_token_usage) = wait_for_guardian_review( + &review_session, + "current-turn", + tokio::time::Instant::now() + Duration::from_millis(10), + /*external_cancel*/ None, + &mut analytics_result, + ) + .await; + + interrupt_response + .await + .expect("interrupt response task should complete"); + assert!(matches!(outcome, GuardianReviewSessionOutcome::TimedOut)); + assert!(keep_review_session); + assert!(!capture_token_usage); + } + + #[tokio::test] + async fn wait_for_guardian_review_cancel_drains_expected_turn_after_stale_terminal_event() { + let (review_session, tx_event, rx_sub) = test_review_session().await; + tx_event + .send(turn_complete_event("prior-turn", Some("stale"), Some(9))) + .await + .expect("queue prior turn completion"); + let tx_interrupt_event = tx_event.clone(); + let interrupt_response = tokio::spawn(async move { + let submission = rx_sub.recv().await.expect("interrupt submission"); + assert!(matches!(submission.op, Op::Interrupt)); + tx_interrupt_event + .send(turn_aborted_event("current-turn")) + .await + .expect("queue current turn abort"); + }); + let external_cancel = CancellationToken::new(); + external_cancel.cancel(); + + let mut analytics_result = GuardianReviewAnalyticsResult::without_session(); + let (outcome, keep_review_session, capture_token_usage) = wait_for_guardian_review( + &review_session, + "current-turn", + tokio::time::Instant::now() + Duration::from_secs(1), + Some(&external_cancel), + &mut analytics_result, + ) + .await; + + interrupt_response + .await + .expect("interrupt response task should complete"); + assert!(matches!(outcome, GuardianReviewSessionOutcome::Aborted)); + assert!(keep_review_session); + assert!(!capture_token_usage); + } + + #[tokio::test] + async fn interrupt_and_drain_turn_ignores_prior_turn_completion() { + let (review_session, tx_event, _rx_sub) = test_review_session().await; + tx_event + .send(turn_complete_event("prior-turn", Some("stale"), Some(9))) + .await + .expect("queue prior turn completion"); + tx_event + .send(turn_aborted_event("current-turn")) + .await + .expect("queue current turn abort"); + + interrupt_and_drain_turn(&review_session.codex, "current-turn") + .await + .expect("drain current turn"); + + assert!(review_session.codex.rx_event.try_recv().is_err()); + } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 6761d87022..78362b6f89 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -30,6 +30,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianAssessmentStatus; @@ -38,6 +39,7 @@ use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnCompleteEvent; use core_test_support::PathBufExt; use core_test_support::TempDirExt; use core_test_support::context_snapshot; @@ -1674,6 +1676,113 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-guardian-1"), + ev_assistant_message( + "msg-guardian-1", + "{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"first guardian rationale\"}", + ), + ev_completed("resp-guardian-1"), + ]), + sse(vec![ + ev_response_created("resp-guardian-2"), + ev_assistant_message( + "msg-guardian-2", + "{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"second guardian rationale\"}", + ), + ev_completed("resp-guardian-2"), + ]), + ], + ) + .await; + + let (session, turn) = guardian_test_session_and_turn(&server).await; + let first_outcome = run_guardian_review_session_for_test( + Arc::clone(&session), + Arc::clone(&turn), + GuardianApprovalRequest::Shell { + id: "shell-1".to_string(), + command: vec!["git".to_string(), "push".to_string()], + cwd: test_path_buf("/repo/codex-rs/core").abs(), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: Some("Need to push the first docs fix.".to_string()), + }, + /*retry_reason*/ None, + guardian_output_schema(), + /*external_cancel*/ None, + ) + .await; + let (GuardianReviewOutcome::Completed(first_assessment), first_metadata) = first_outcome else { + panic!("expected first guardian assessment"); + }; + assert_eq!(first_assessment.rationale, "first guardian rationale"); + assert!(matches!( + first_metadata.guardian_session_kind, + Some(codex_analytics::GuardianReviewSessionKind::TrunkNew) + )); + + session + .guardian_review_session + .send_trunk_event_raw_for_test(Event { + id: "stale-turn".to_string(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "stale-turn".to_string(), + last_agent_message: Some( + "{\"risk_level\":\"high\",\"user_authorization\":\"low\",\"outcome\":\"deny\",\"rationale\":\"stale guardian rationale\"}" + .to_string(), + ), + completed_at: None, + duration_ms: None, + time_to_first_token_ms: Some(1), + }), + }) + .await; + + let second_outcome = run_guardian_review_session_for_test( + Arc::clone(&session), + Arc::clone(&turn), + GuardianApprovalRequest::Shell { + id: "shell-2".to_string(), + command: vec!["git".to_string(), "push".to_string()], + cwd: test_path_buf("/repo/codex-rs/core").abs(), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: Some("Need to push the second docs fix.".to_string()), + }, + /*retry_reason*/ None, + guardian_output_schema(), + /*external_cancel*/ None, + ) + .await; + let (GuardianReviewOutcome::Completed(second_assessment), second_metadata) = second_outcome + else { + panic!("expected second guardian assessment"); + }; + assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert_eq!(second_assessment.rationale, "second guardian rationale"); + assert!(matches!( + second_metadata.guardian_session_kind, + Some(codex_analytics::GuardianReviewSessionKind::TrunkReused) + )); + + assert_eq!( + request_log.requests().len(), + 2, + "the reused trunk should wait for the real follow-up review" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index bd0cdd885d..414a587a23 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -119,6 +119,7 @@ pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; pub use thread_manager::StartThreadOptions; pub use thread_manager::ThreadManager; +pub use thread_manager::ThreadShutdownReport; pub use thread_manager::build_models_manager; pub use thread_manager::thread_store_from_config; pub use web_search::web_search_action_detail; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index c93ab296b1..1619946545 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -141,7 +141,8 @@ pub(crate) async fn handle_mcp_tool_call( let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME { app_tool_policy.approval } else { - custom_mcp_tool_approval_mode(turn_context.as_ref(), &server, &tool_name) + custom_mcp_tool_approval_mode(sess.as_ref(), turn_context.as_ref(), &server, &tool_name) + .await }; if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled { @@ -722,12 +723,13 @@ const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; -fn custom_mcp_tool_approval_mode( +async fn custom_mcp_tool_approval_mode( + sess: &Session, turn_context: &TurnContext, server: &str, tool_name: &str, ) -> AppToolApproval { - turn_context + let user_configured_mode = turn_context .config .config_layer_stack .effective_config() @@ -739,6 +741,28 @@ fn custom_mcp_tool_approval_mode( }) .and_then(|servers| { let server_config = servers.get(server)?; + Some( + server_config + .tools + .get(tool_name) + .and_then(|tool| tool.approval_mode) + .or(server_config.default_tools_approval_mode) + .unwrap_or_default(), + ) + }); + if let Some(user_configured_mode) = user_configured_mode { + return user_configured_mode; + } + + sess.services + .plugins_manager + .plugins_for_config(turn_context.config.as_ref()) + .await + .plugins() + .iter() + .filter(|plugin| plugin.is_active()) + .find_map(|plugin| { + let server_config = plugin.mcp_servers.get(server)?; server_config .tools .get(tool_name) @@ -1717,7 +1741,7 @@ async fn maybe_persist_mcp_tool_approval( persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name) .await } else { - persist_custom_mcp_tool_approval(&turn_context.config, &key.server, &tool_name).await + persist_non_app_mcp_tool_approval(sess, &turn_context.config, &key.server, &tool_name).await }; if let Err(err) = persist_result { @@ -1755,24 +1779,81 @@ async fn persist_codex_app_tool_approval( .await } +#[cfg(test)] async fn persist_custom_mcp_tool_approval( config: &Config, server: &str, tool_name: &str, ) -> anyhow::Result<()> { - let config_folder = if let Some(project_config_folder) = - project_mcp_tool_approval_config_folder(config, server) - { - project_config_folder - } else { - let servers = load_global_mcp_servers(&config.codex_home).await?; - if !servers.contains_key(server) { - anyhow::bail!("MCP server `{server}` is not configured in config.toml"); - } - config.codex_home.clone() + let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? else { + anyhow::bail!("MCP server `{server}` is not configured in config.toml"); }; - ConfigEditsBuilder::new(&config_folder) + persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await +} + +async fn persist_non_app_mcp_tool_approval( + sess: &Session, + config: &Config, + server: &str, + tool_name: &str, +) -> anyhow::Result<()> { + if let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? { + return persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await; + } + + let plugin_config_name = sess + .services + .plugins_manager + .plugins_for_config(config) + .await + .plugins() + .iter() + .filter(|plugin| plugin.is_active()) + .find(|plugin| plugin.mcp_servers.contains_key(server)) + .map(|plugin| plugin.config_name.clone()); + + if let Some(plugin_config_name) = plugin_config_name { + return ConfigEditsBuilder::new(&config.codex_home) + .with_edits([ConfigEdit::SetPath { + segments: vec![ + "plugins".to_string(), + plugin_config_name, + "mcp_servers".to_string(), + server.to_string(), + "tools".to_string(), + tool_name.to_string(), + "approval_mode".to_string(), + ], + value: value("approve"), + }]) + .apply() + .await; + } + + anyhow::bail!("MCP server `{server}` is not configured in config.toml or an enabled plugin") +} + +async fn custom_mcp_tool_approval_config_folder( + config: &Config, + server: &str, +) -> anyhow::Result> { + if let Some(project_config_folder) = project_mcp_tool_approval_config_folder(config, server) { + return Ok(Some(project_config_folder)); + } + + let servers = load_global_mcp_servers(&config.codex_home).await?; + Ok(servers + .contains_key(server) + .then(|| config.codex_home.clone())) +} + +async fn persist_custom_mcp_tool_approval_at( + config_folder: &AbsolutePathBuf, + server: &str, + tool_name: &str, +) -> anyhow::Result<()> { + ConfigEditsBuilder::new(config_folder) .with_edits([ConfigEdit::SetPath { segments: vec![ "mcp_servers".to_string(), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index f7fd1b08c2..6f9c323eb5 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -69,6 +69,30 @@ fn approval_metadata( } } +fn write_sample_plugin_mcp(codex_home: &std::path::Path) { + let plugin_root = codex_home.join("plugins/cache/test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample" +}"#, + ) + .expect("write plugin manifest"); + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ) + .expect("write plugin mcp config"); +} + fn prompt_options( allow_session_remember: bool, allow_persistent_approval: bool, @@ -1486,23 +1510,116 @@ approval_mode = "prompt" .build() .await .expect("load config"); - let (_session, mut turn_context) = make_session_and_context().await; + let (session, mut turn_context) = make_session_and_context().await; turn_context.config = Arc::new(config); assert_eq!( - custom_mcp_tool_approval_mode(&turn_context, "docs", "read"), + custom_mcp_tool_approval_mode(&session, &turn_context, "docs", "read").await, AppToolApproval::Approve ); assert_eq!( - custom_mcp_tool_approval_mode(&turn_context, "docs", "search"), + custom_mcp_tool_approval_mode(&session, &turn_context, "docs", "search").await, AppToolApproval::Prompt ); assert_eq!( - custom_mcp_tool_approval_mode(&turn_context, "unknown", "search"), + custom_mcp_tool_approval_mode(&session, &turn_context, "unknown", "search").await, AppToolApproval::Auto ); } +#[tokio::test] +async fn custom_mcp_tool_approval_mode_uses_plugin_mcp_policy() { + let (session, mut turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + write_sample_plugin_mcp(codex_home.as_path()); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[plugins."sample@test"] +enabled = true + +[plugins."sample@test".mcp_servers.sample] +default_tools_approval_mode = "prompt" + +[plugins."sample@test".mcp_servers.sample.tools.search] +approval_mode = "approve" +"#, + ) + .expect("seed config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + .expect("load config"); + turn_context.config = Arc::new(config); + session.services.plugins_manager.clear_cache(); + + assert_eq!( + custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "read").await, + AppToolApproval::Prompt + ); + assert_eq!( + custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "search").await, + AppToolApproval::Approve + ); +} + +#[tokio::test] +async fn custom_mcp_tool_approval_mode_uses_updated_plugin_mcp_policy_after_cache_warm() { + let (session, mut turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + write_sample_plugin_mcp(codex_home.as_path()); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[plugins."sample@test"] +enabled = true +"#, + ) + .expect("seed config"); + let initial_config = ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + .expect("load initial config"); + session + .services + .plugins_manager + .plugins_for_config(&initial_config) + .await; + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[plugins."sample@test"] +enabled = true + +[plugins."sample@test".mcp_servers.sample.tools.search] +approval_mode = "approve" +"#, + ) + .expect("update config"); + let updated_config = ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + .expect("load updated config"); + turn_context.config = Arc::new(updated_config); + + assert_eq!( + custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "search").await, + AppToolApproval::Approve + ); +} + #[tokio::test] async fn maybe_persist_mcp_tool_approval_reloads_session_config() { let (session, turn_context) = make_session_and_context().await; @@ -1584,6 +1701,56 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_serve assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); } +#[tokio::test] +async fn maybe_persist_mcp_tool_approval_writes_plugin_mcp_policy() { + let (session, mut turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + write_sample_plugin_mcp(codex_home.as_path()); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[plugins."sample@test"] +enabled = true +"#, + ) + .expect("seed config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + .expect("load config"); + turn_context.config = Arc::new(config); + session.services.plugins_manager.clear_cache(); + let key = McpToolApprovalKey { + server: "sample".to_string(), + connector_id: None, + tool_name: "search".to_string(), + }; + + maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await; + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse config"); + let tool = parsed + .plugins + .get("sample@test") + .and_then(|plugin| plugin.mcp_servers.get("sample")) + .and_then(|server| server.tools.get("search")) + .expect("sample/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); + assert!(contents.contains(r#"[plugins."sample@test".mcp_servers.sample.tools.search]"#)); + assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); +} + #[tokio::test] async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_server() { let (session, mut turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 5a1a3588fd..ba14f87040 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -8,21 +8,10 @@ use crate::config::Config; use codex_config::types::ToolSuggestDiscoverableType; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST; use codex_features::Feature; use codex_tools::DiscoverablePluginInfo; -const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ - "github@openai-curated", - "notion@openai-curated", - "slack@openai-curated", - "gmail@openai-curated", - "google-calendar@openai-curated", - "google-drive@openai-curated", - "linear@openai-curated", - "figma@openai-curated", - "computer-use@openai-bundled", -]; - const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ OPENAI_BUNDLED_MARKETPLACE_NAME, OPENAI_CURATED_MARKETPLACE_NAME, diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index 20a03e0c84..f9e385fa8e 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -42,6 +42,35 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug ); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace( + &curated_root, + &["teams", "sharepoint", "outlook-email", "outlook-calendar"], + ); + write_plugins_feature_config(codex_home.path()); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .await + .unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec![ + "outlook-calendar@openai-curated".to_string(), + "outlook-email@openai-curated".to_string(), + "sharepoint@openai-curated".to_string(), + "teams@openai-curated".to_string(), + ] + ); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_deduplicates_allowlisted_configured_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index f59bbad558..595378637a 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -7,6 +7,7 @@ use crate::config::edit::ConfigEditsBuilder; use codex_analytics::AnalyticsEventsClient; use codex_config::ConfigLayerStack; use codex_config::types::PluginConfig; +use codex_config::version_for_toml; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::installed_marketplaces::installed_marketplace_roots_from_layer_stack; use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home; @@ -359,7 +360,7 @@ pub struct PluginsManager { featured_plugin_ids_cache: RwLock>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - cached_enabled_outcome: RwLock>, + cached_enabled_outcome: RwLock>, // TODO(remote plugins): reset this cache when ChatGPT auth/account state changes so stale // remote installed state cannot remain effective for a different account. remote_installed_plugins_cache: RwLock>>, @@ -369,6 +370,13 @@ pub struct PluginsManager { analytics_events_client: RwLock>, } +#[derive(Clone)] +struct CachedPluginLoadOutcome { + config_version: String, + plugin_hooks_enabled: bool, + outcome: PluginLoadOutcome, +} + impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_restriction_product(codex_home, Some(Product::Codex)) @@ -436,7 +444,12 @@ impl PluginsManager { return PluginLoadOutcome::default(); } - if !force_reload && let Some(outcome) = self.cached_enabled_outcome() { + let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks); + let config_version = version_for_toml(&config.config_layer_stack.effective_config()); + if !force_reload + && let Some(outcome) = + self.cached_enabled_outcome(&config_version, plugin_hooks_enabled) + { return outcome; } @@ -445,6 +458,7 @@ impl PluginsManager { self.remote_installed_plugin_configs(config), &self.store, self.restriction_product, + plugin_hooks_enabled, ) .await; log_plugin_load_errors(&outcome); @@ -452,7 +466,11 @@ impl PluginsManager { Ok(cache) => cache, Err(err) => err.into_inner(), }; - *cache = Some(outcome.clone()); + *cache = Some(CachedPluginLoadOutcome { + config_version, + plugin_hooks_enabled, + outcome: outcome.clone(), + }); outcome } @@ -473,29 +491,62 @@ impl PluginsManager { *cached_enabled_outcome = None; } - /// Resolve plugin skill roots for a config layer stack without touching the plugins cache. - pub async fn effective_skill_roots_for_layer_stack( + /// Load plugins for a config layer stack without touching the plugins cache. + pub async fn plugins_for_layer_stack( &self, config_layer_stack: &ConfigLayerStack, config: &Config, - ) -> Vec { + plugin_hooks_feature_enabled: bool, + ) -> PluginLoadOutcome { if !config.features.enabled(Feature::Plugins) { - return Vec::new(); + return PluginLoadOutcome::default(); } load_plugins_from_layer_stack( config_layer_stack, self.remote_installed_plugin_configs(config), &self.store, self.restriction_product, + plugin_hooks_feature_enabled, + ) + .await + } + + /// Resolve plugin skill roots for a config layer stack without touching the plugins cache. + pub async fn effective_skill_roots_for_layer_stack( + &self, + config_layer_stack: &ConfigLayerStack, + config: &Config, + ) -> Vec { + self.plugins_for_layer_stack( + config_layer_stack, + config, + config.features.enabled(Feature::PluginHooks), ) .await .effective_skill_roots() } - fn cached_enabled_outcome(&self) -> Option { + fn cached_enabled_outcome( + &self, + config_version: &str, + plugin_hooks_enabled: bool, + ) -> Option { match self.cached_enabled_outcome.read() { - Ok(cache) => cache.clone(), - Err(err) => err.into_inner().clone(), + Ok(cache) => cache + .as_ref() + .filter(|cached| { + cached.config_version == config_version + && cached.plugin_hooks_enabled == plugin_hooks_enabled + }) + .map(|cached| cached.outcome.clone()), + Err(err) => err + .into_inner() + .as_ref() + .filter(|cached| { + cached.config_version == config_version + && cached.plugin_hooks_enabled == plugin_hooks_enabled + }) + .map(|cached| cached.outcome.clone()), } } diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index a72df1881c..73a77e69cb 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -9,11 +9,13 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::AppToolApproval; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::McpServerConfig; +use codex_config::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use codex_core_plugins::installed_marketplaces::marketplace_install_root; use codex_core_plugins::loader::load_plugins_from_layer_stack; @@ -95,6 +97,18 @@ fn run_git(repo: &Path, args: &[&str]) { } fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { + plugin_config_toml_with_plugin_hooks( + enabled, + plugins_feature_enabled, + /*plugin_hooks_feature_enabled*/ false, + ) +} + +fn plugin_config_toml_with_plugin_hooks( + enabled: bool, + plugins_feature_enabled: bool, + plugin_hooks_feature_enabled: bool, +) -> String { let mut root = toml::map::Map::new(); let mut features = toml::map::Map::new(); @@ -102,6 +116,10 @@ fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { "plugins".to_string(), Value::Boolean(plugins_feature_enabled), ); + features.insert( + "plugin_hooks".to_string(), + Value::Boolean(plugin_hooks_feature_enabled), + ); root.insert("features".to_string(), Value::Table(features)); let mut plugin = toml::map::Map::new(); @@ -247,6 +265,74 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { ); } +#[tokio::test] +async fn load_plugins_applies_plugin_mcp_server_policy() { + 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" +}"#, + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp", + "default_tools_approval_mode": "prompt", + "enabled_tools": ["read", "search"], + "tools": { + "search": { "approval_mode": "prompt" } + } + } + } +}"#, + ); + let config_toml = r#" +[features] +plugins = true + +[plugins."sample@test"] +enabled = true + +[plugins."sample@test".mcp_servers.sample] +enabled = false +default_tools_approval_mode = "approve" +enabled_tools = ["search"] +disabled_tools = ["delete"] + +[plugins."sample@test".mcp_servers.sample.tools.search] +approval_mode = "approve" +"#; + + let outcome = load_plugins_from_config(config_toml, codex_home.path()).await; + let server = outcome.plugins()[0] + .mcp_servers + .get("sample") + .expect("sample server"); + + assert!(!server.enabled); + assert_eq!( + server.default_tools_approval_mode, + Some(AppToolApproval::Approve) + ); + assert_eq!(server.enabled_tools, Some(vec!["search".to_string()])); + assert_eq!(server.disabled_tools, Some(vec!["delete".to_string()])); + assert_eq!( + server.tools.get("search"), + Some(&McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + }) + ); +} + #[tokio::test] async fn remote_installed_cache_adds_plugin_skill_roots_without_marketplace_config() { let codex_home = TempDir::new().unwrap(); @@ -997,6 +1083,61 @@ async fn load_plugins_returns_empty_when_feature_disabled() { assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() { + 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"}"#, + ); + write_file( + &plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [{ "type": "command", "command": "echo plugin hook" }] + } + ] + } +}"#, + ); + + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml_with_plugin_hooks( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + /*plugin_hooks_feature_enabled*/ false, + ), + ); + let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; + let without_plugin_hooks = manager + .plugins_for_config(&config_without_plugin_hooks) + .await; + assert!( + without_plugin_hooks + .effective_plugin_hook_sources() + .is_empty() + ); + + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml_with_plugin_hooks( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + /*plugin_hooks_feature_enabled*/ true, + ), + ); + let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; + let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await; + assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1); +} + #[tokio::test] async fn load_plugins_rejects_invalid_plugin_keys() { let codex_home = TempDir::new().unwrap(); @@ -3365,6 +3506,7 @@ async fn load_plugins_ignores_project_config_files() { std::collections::HashMap::new(), &PluginStore::new(codex_home.path().to_path_buf()), Some(Product::Codex), + /*plugin_hooks_enabled*/ false, ) .await; diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 46431c59d8..2a91f2e54c 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -4,9 +4,7 @@ use std::sync::Arc; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; -use codex_features::Feature; use codex_login::AuthManager; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -41,11 +39,6 @@ pub async fn build_prompt_input( &config, Arc::clone(&auth_manager), SessionSource::Exec, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await), /*analytics_events_client*/ None, ); diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index b354861605..624331ea62 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -161,6 +161,7 @@ pub(super) async fn user_input_or_turn_inner( approvals_reviewer, sandbox_policy: Some(sandbox_policy), permission_profile, + active_permission_profile: None, windows_sandbox_level: None, collaboration_mode, reasoning_summary: summary, @@ -180,6 +181,7 @@ pub(super) async fn user_input_or_turn_inner( approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level, model, effort, @@ -211,6 +213,7 @@ pub(super) async fn user_input_or_turn_inner( approvals_reviewer, sandbox_policy, permission_profile, + active_permission_profile, windows_sandbox_level, collaboration_mode, reasoning_summary: summary, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0c5af3fc5e..65a5366dcc 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -91,6 +91,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; @@ -603,6 +604,7 @@ impl Codex { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 03849f7e07..f4e8c2a989 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -60,6 +60,9 @@ pub(crate) struct SessionConfiguration { pub(super) approvals_reviewer: ApprovalsReviewer, /// Canonical permission profile for the session. pub(super) permission_profile: Constrained, + /// Named or implicit built-in permissions profile selected from config, if + /// any. + pub(super) active_permission_profile: Option, pub(super) windows_sandbox_level: WindowsSandboxLevel, /// Absolute working directory that should be treated as the *root* of the @@ -97,6 +100,10 @@ impl SessionConfiguration { self.permission_profile.get().clone() } + pub(super) fn active_permission_profile(&self) -> Option { + self.active_permission_profile.clone() + } + pub(super) fn sandbox_policy(&self) -> SandboxPolicy { self.permission_profile() .to_legacy_sandbox_policy(&self.cwd) @@ -127,6 +134,7 @@ impl SessionConfiguration { approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), + active_permission_profile: self.active_permission_profile(), cwd: self.cwd.clone(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), @@ -206,10 +214,19 @@ impl SessionConfiguration { } if let Some(permission_profile) = updates.permission_profile.clone() { + let active_permission_profile = + updates.active_permission_profile.clone().or_else(|| { + if permission_profile == self.permission_profile() { + self.active_permission_profile.clone() + } else { + None + } + }); next_configuration.set_permission_profile_projection( permission_profile, Some(¤t_file_system_sandbox_policy), )?; + next_configuration.active_permission_profile = active_permission_profile; } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( @@ -225,6 +242,7 @@ impl SessionConfiguration { network_sandbox_policy, ), )?; + next_configuration.active_permission_profile = None; } else if cwd_changed && file_system_policy_matches_legacy && file_system_policy_has_rebindable_project_root_write @@ -285,6 +303,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) approvals_reviewer: Option, pub(crate) sandbox_policy: Option, pub(crate) permission_profile: Option, + pub(crate) active_permission_profile: Option, pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, @@ -887,6 +906,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, permission_profile: session_configuration.permission_profile(), + active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e7484e43b7..8286c7ce02 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2324,6 +2324,7 @@ async fn set_rate_limits_retains_previous_credits() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -2426,6 +2427,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -2873,6 +2875,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3309,6 +3312,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3413,6 +3417,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3625,6 +3630,7 @@ async fn make_session_with_config_and_rx( approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -4206,6 +4212,7 @@ fn op_kind_distinguishes_turn_ops() { approvals_reviewer: None, sandbox_policy: None, permission_profile: None, + active_permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -4839,6 +4846,7 @@ where approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 6c2f29d9c0..34ed487fc5 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -110,11 +110,7 @@ pub fn models_manager_with_provider( provider: ModelProviderInfo, ) -> SharedModelsManager { let provider = create_model_provider(provider, Some(auth_manager)); - provider.models_manager( - codex_home, - /*config_model_catalog*/ None, - Default::default(), - ) + provider.models_manager(codex_home, /*config_model_catalog*/ None) } pub fn get_model_offline(model: Option<&str>) -> String { @@ -130,7 +126,5 @@ pub fn all_model_presets() -> &'static Vec { } pub fn builtin_collaboration_mode_presets() -> Vec { - collaboration_mode_presets::builtin_collaboration_mode_presets( - collaboration_mode_presets::CollaborationModesConfig::default(), - ) + collaboration_mode_presets::builtin_collaboration_mode_presets() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2a83eb4511..3587dc937e 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -28,7 +28,6 @@ use codex_login::AuthManager; use codex_login::CodexAuth; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_protocol::ThreadId; @@ -255,13 +254,11 @@ pub(crate) struct ThreadManagerState { pub fn build_models_manager( config: &Config, auth_manager: Arc, - collaboration_modes_config: CollaborationModesConfig, ) -> SharedModelsManager { let provider = create_model_provider(config.model_provider.clone(), Some(auth_manager)); provider.models_manager( config.codex_home.to_path_buf(), config.model_catalog.clone(), - collaboration_modes_config, ) } @@ -281,7 +278,6 @@ impl ThreadManager { config: &Config, auth_manager: Arc, session_source: SessionSource, - collaboration_modes_config: CollaborationModesConfig, environment_manager: Arc, analytics_events_client: Option, ) -> Self { @@ -303,11 +299,7 @@ impl ThreadManager { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), thread_created_tx, - models_manager: build_models_manager( - config, - auth_manager.clone(), - collaboration_modes_config, - ), + models_manager: build_models_manager(config, auth_manager.clone()), environment_manager, skills_manager, plugins_manager, @@ -378,11 +370,7 @@ impl ThreadManager { threads: Arc::new(RwLock::new(HashMap::new())), thread_created_tx, models_manager: create_model_provider(provider, Some(auth_manager.clone())) - .models_manager( - codex_home, - /*config_model_catalog*/ None, - CollaborationModesConfig::default(), - ), + .models_manager(codex_home, /*config_model_catalog*/ None), environment_manager, skills_manager, plugins_manager, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 348edaa221..c79321b94b 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -6,7 +6,6 @@ use crate::session::tests::make_session_and_context; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; use codex_features::Feature; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; @@ -392,7 +391,6 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { &config, auth_manager.clone(), SessionSource::Exec, - CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); @@ -499,7 +497,6 @@ async fn new_uses_active_provider_for_model_refresh() { &config, auth_manager, SessionSource::Exec, - CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); @@ -710,7 +707,6 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor &config, auth_manager.clone(), SessionSource::Exec, - CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); @@ -814,7 +810,6 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { &config, auth_manager.clone(), SessionSource::Exec, - CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); @@ -907,7 +902,6 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ &config, auth_manager.clone(), SessionSource::Exec, - CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); @@ -1046,7 +1040,6 @@ async fn resumed_thread_activates_paused_goal_and_continues_on_request() -> anyh &config, auth_manager.clone(), SessionSource::Exec, - CollaborationModesConfig::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 2c94145d31..eea6612762 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -5,13 +5,14 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_protocol::config_types::ModeKind; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; use codex_tools::normalize_request_user_input_args; use codex_tools::request_user_input_unavailable_message; pub struct RequestUserInputHandler { - pub default_mode_request_user_input: bool, + pub available_modes: Vec, } impl ToolHandler for RequestUserInputHandler { @@ -46,9 +47,7 @@ impl ToolHandler for RequestUserInputHandler { } let mode = session.collaboration_mode().await.mode; - if let Some(message) = - request_user_input_unavailable_message(mode, self.default_mode_request_user_input) - { + if let Some(message) = request_user_input_unavailable_message(mode, &self.available_modes) { return Err(FunctionCallError::RespondToModel(message)); } diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index 811e4a6686..7c577c54be 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -23,7 +23,7 @@ async fn multi_agent_v2_request_user_input_rejects_subagent_threads() { }); let result = RequestUserInputHandler { - default_mode_request_user_input: true, + available_modes: Vec::new(), } .handle(ToolInvocation { session: Arc::new(session), diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index c167fdbc3c..308d8c46f7 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -166,7 +166,7 @@ pub(crate) fn build_specs_with_discoverable_tools( let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend)); let request_permissions_handler = Arc::new(RequestPermissionsHandler); let request_user_input_handler = Arc::new(RequestUserInputHandler { - default_mode_request_user_input: config.default_mode_request_user_input, + available_modes: config.request_user_input_available_modes.clone(), }); let deferred_dynamic_tools = dynamic_tools .iter() diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index f3ba9cc48d..afa586dc62 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -298,31 +298,6 @@ fn build_specs_with_unavailable_tools( ) } -#[tokio::test] -async fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() { - let mut model_info = model_info_from_models_json("gpt-5.4").await; - model_info.shell_type = ConfigShellToolType::UnifiedExec; - let features = Features::with_defaults(); - let available_models = Vec::new(); - let config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::workspace_write(), - windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, - }); - - let expected_shell_type = if cfg!(target_os = "windows") { - ConfigShellToolType::ShellCommand - } else { - ConfigShellToolType::UnifiedExec - }; - assert_eq!(config.shell_type, expected_shell_type); -} - #[tokio::test] async fn get_memory_requires_feature_flag() { let config = test_config().await; diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 97902ce1e1..f6a338b9ac 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; +use codex_utils_string::to_ascii_json_string; use serde::Serialize; use serde_json::Value; use tokio::task::JoinHandle; @@ -71,7 +72,7 @@ pub(crate) struct TurnMetadataBag { impl TurnMetadataBag { fn to_header_value(&self) -> Option { - serde_json::to_string(self).ok() + to_ascii_json_string(self).ok() } } @@ -101,7 +102,7 @@ fn merge_turn_metadata( .or_insert_with(|| Value::String(value.clone())); } } - serde_json::to_string(&metadata).ok() + to_ascii_json_string(&metadata).ok() } fn build_turn_metadata_bag( diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 5201eb3560..6504eadd67 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -7,6 +7,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use core_test_support::PathBufExt; use core_test_support::PathExt; +use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::HashMap; use tempfile::TempDir; @@ -15,7 +16,7 @@ use tokio::process::Command; #[tokio::test] async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { let temp_dir = TempDir::new().expect("temp dir"); - let repo_path = temp_dir.path().join("repo").abs(); + let repo_path = temp_dir.path().join("repo-東京").abs(); std::fs::create_dir_all(&repo_path).expect("create repo"); Command::new("git") @@ -54,7 +55,16 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { let header = build_turn_metadata_header(&repo_path, Some("none")) .await .expect("header"); + assert!(header.is_ascii()); + assert!(!header.contains("東京")); let parsed: Value = serde_json::from_str(&header).expect("valid json"); + let expected_repo_path = repo_path.to_string_lossy().into_owned(); + let actual_repo_path = parsed + .get("workspaces") + .and_then(Value::as_object) + .and_then(|workspaces| workspaces.keys().next()) + .expect("workspace path"); + assert_eq!(actual_repo_path, &expected_repo_path); let workspace = parsed .get("workspaces") .and_then(Value::as_object) @@ -191,6 +201,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( ); state.set_responsesapi_client_metadata(HashMap::from([ ("fiber_run_id".to_string(), "fiber-123".to_string()), + ("origin".to_string(), "東京".to_string()), ("session_id".to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), ( @@ -201,9 +212,12 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( state.set_turn_started_at_unix_ms(/*turn_started_at_unix_ms*/ 1_700_000_000_123); let header = state.current_header_value().expect("header"); + assert!(header.is_ascii()); + assert!(!header.contains("東京")); let json: Value = serde_json::from_str(&header).expect("json"); assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123")); + assert_eq!(json["origin"].as_str(), Some("東京")); assert_eq!(json["session_id"].as_str(), Some("session-a")); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 600a58c720..374116e3fe 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -27,7 +27,6 @@ use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelsResponse; @@ -429,7 +428,6 @@ impl TestCodexBuilder { &config, codex_core::test_support::auth_manager_from_auth(auth.clone()), SessionSource::Exec, - CollaborationModesConfig::default(), Arc::clone(&environment_manager), /*analytics_events_client*/ None, ) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index b8871cf524..9ed83605c2 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -14,7 +14,6 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; @@ -1106,11 +1105,6 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { &config, auth_manager, SessionSource::Exec, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, ); diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 87b5eb46fc..f6db9a7098 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -9,7 +9,6 @@ use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::ExecutorFileSystem; use codex_login::CodexAuth; -use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Op; @@ -240,7 +239,6 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> { &config, codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("dummy")), SessionSource::Exec, - CollaborationModesConfig::default(), Arc::new(EnvironmentManager::disabled_for_tests( ExecServerRuntimePaths::new( std::env::current_exe()?, diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index b4e3adfe53..7e3d4c6843 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -88,6 +88,7 @@ Fetch the built-in collaboration mode presets with `collaborationMode/list`. Thi - `data` - ordered list of collaboration mode masks (partial settings to apply on top of the base mode) - For tri-state fields like `reasoning_effort` and `developer_instructions`, omit the field to keep the current value, set it to `null` to clear it, or set a concrete value to update it. + - Built-in presets do not set `model`. The Plan preset sets `reasoning_effort` to medium; clients keep or override model separately. When sending `turn/start` with `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7846536206..f2f0ed030b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -25,6 +25,8 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; +use codex_app_server_protocol::PermissionProfileModificationParams; +use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; @@ -76,13 +78,14 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -399,6 +402,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result approvals_reviewer: None, sandbox_mode, permission_profile: None, + default_permissions: None, cwd: resolved_cwd, model_provider: model_provider.clone(), service_tier: None, @@ -665,37 +669,24 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // Handle resume subcommand through existing `thread/list` + `thread/resume` // APIs so exec no longer reaches into rollout storage directly. - let (primary_thread_id, fallback_session_configured) = - if let Some(ExecCommand::Resume(args)) = command.as_ref() { - if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? { - let response: ThreadResumeResponse = send_request_with_response( - &client, - ClientRequest::ThreadResume { - request_id: request_ids.next(), - params: thread_resume_params_from_config(&config, thread_id), - }, - "thread/resume", - ) - .await - .map_err(anyhow::Error::msg)?; - let session_configured = session_configured_from_thread_resume_response(&response) + let (primary_thread_id, fallback_session_configured) = if let Some(ExecCommand::Resume(args)) = + command.as_ref() + { + if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? { + let response: ThreadResumeResponse = send_request_with_response( + &client, + ClientRequest::ThreadResume { + request_id: request_ids.next(), + params: thread_resume_params_from_config(&config, thread_id), + }, + "thread/resume", + ) + .await + .map_err(anyhow::Error::msg)?; + let session_configured = + session_configured_from_thread_resume_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) - } else { - let response: ThreadStartResponse = send_request_with_response( - &client, - ClientRequest::ThreadStart { - request_id: request_ids.next(), - params: thread_start_params_from_config(&config), - }, - "thread/start", - ) - .await - .map_err(anyhow::Error::msg)?; - let session_configured = session_configured_from_thread_start_response(&response) - .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) - } + (session_configured.session_id, session_configured) } else { let response: ThreadStartResponse = send_request_with_response( &client, @@ -707,10 +698,26 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; - let session_configured = session_configured_from_thread_start_response(&response) - .map_err(anyhow::Error::msg)?; + let session_configured = + session_configured_from_thread_start_response(&response, &config) + .map_err(anyhow::Error::msg)?; (session_configured.session_id, session_configured) - }; + } + } else { + let response: ThreadStartResponse = send_request_with_response( + &client, + ClientRequest::ThreadStart { + request_id: request_ids.next(), + params: thread_start_params_from_config(&config), + }, + "thread/start", + ) + .await + .map_err(anyhow::Error::msg)?; + let session_configured = session_configured_from_thread_start_response(&response, &config) + .map_err(anyhow::Error::msg)?; + (session_configured.session_id, session_configured) + }; let primary_thread_id_for_span = primary_thread_id.to_string(); // Use the start/resume response as the authoritative bootstrap payload. @@ -745,7 +752,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { items, output_schema, } => { - let permission_profile = Some(config.permissions.permission_profile().into()); let response: TurnStartResponse = send_request_with_response( &client, ClientRequest::TurnStart { @@ -759,7 +765,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, sandbox_policy: None, - permission_profile, + permissions: None, model: None, service_tier: None, effort: default_effort, @@ -916,14 +922,21 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { } fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { + let permissions = permissions_selection_from_config(config); + let sandbox = permissions.is_none().then(|| { + sandbox_mode_from_permission_profile( + &config.permissions.permission_profile(), + config.cwd.as_path(), + ) + }); ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: None, - permission_profile: Some(config.permissions.permission_profile().into()), + sandbox: sandbox.flatten(), + permissions, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), ..ThreadStartParams::default() @@ -931,6 +944,13 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { } fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams { + let permissions = permissions_selection_from_config(config); + let sandbox = permissions.is_none().then(|| { + sandbox_mode_from_permission_profile( + &config.permissions.permission_profile(), + config.cwd.as_path(), + ) + }); ThreadResumeParams { thread_id, model: config.model.clone(), @@ -938,13 +958,63 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa cwd: Some(config.cwd.to_string_lossy().to_string()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: None, - permission_profile: Some(config.permissions.permission_profile().into()), + sandbox: sandbox.flatten(), + permissions, config: config_request_overrides_from_config(config), ..ThreadResumeParams::default() } } +fn permissions_selection_from_config(config: &Config) -> Option { + config + .permissions + .active_permission_profile() + .map(permissions_selection_from_active_profile) +} + +fn permissions_selection_from_active_profile( + active: ActivePermissionProfile, +) -> PermissionProfileSelectionParams { + let modifications = active + .modifications + .into_iter() + .map(|modification| match modification { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + PermissionProfileModificationParams::AdditionalWritableRoot { path } + } + }) + .collect::>(); + PermissionProfileSelectionParams::Profile { + id: active.id, + modifications: (!modifications.is_empty()).then_some(modifications), + } +} + +fn sandbox_mode_from_permission_profile( + permission_profile: &PermissionProfile, + cwd: &Path, +) -> Option { + match permission_profile { + PermissionProfile::Disabled => { + Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + } + PermissionProfile::External { .. } => None, + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + permission_profile + .network_sandbox_policy() + .is_enabled() + .then_some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + } else if file_system_policy.can_write_path_with_cwd(cwd, cwd) { + Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite) + } else { + Some(codex_app_server_protocol::SandboxMode::ReadOnly) + } + } + } +} + fn config_request_overrides_from_config(config: &Config) -> Option> { config .active_profile @@ -977,6 +1047,7 @@ where fn session_configured_from_thread_start_response( response: &ThreadStartResponse, + config: &Config, ) -> Result { session_configured_from_thread_response( &response.thread.id, @@ -987,8 +1058,12 @@ fn session_configured_from_thread_start_response( response.service_tier, response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response.sandbox.to_core(), - response.permission_profile.clone().map(Into::into), + response + .permission_profile + .clone() + .map(Into::into) + .unwrap_or_else(|| config.permissions.permission_profile()), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -996,6 +1071,7 @@ fn session_configured_from_thread_start_response( fn session_configured_from_thread_resume_response( response: &ThreadResumeResponse, + config: &Config, ) -> Result { session_configured_from_thread_response( &response.thread.id, @@ -1006,8 +1082,12 @@ fn session_configured_from_thread_resume_response( response.service_tier, response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response.sandbox.to_core(), - response.permission_profile.clone().map(Into::into), + response + .permission_profile + .clone() + .map(Into::into) + .unwrap_or_else(|| config.permissions.permission_profile()), + response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -1035,8 +1115,8 @@ fn session_configured_from_thread_response( service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - sandbox_policy: SandboxPolicy, - permission_profile: Option, + permission_profile: PermissionProfile, + active_permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, ) -> Result { @@ -1052,9 +1132,8 @@ fn session_configured_from_thread_response( service_tier, approval_policy, approvals_reviewer, - permission_profile: permission_profile.unwrap_or_else(|| { - PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, cwd.as_path()) - }), + permission_profile, + active_permission_profile, cwd, reasoning_effort, history_log_id: 0, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index bcb17fb87d..648d512689 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -363,8 +363,8 @@ async fn thread_start_params_include_review_policy_when_review_policy_is_manual_ ); assert_eq!(params.sandbox, None); assert_eq!( - params.permission_profile, - Some(config.permissions.permission_profile().into()) + params.permissions, + permissions_selection_from_config(&config) ); } @@ -391,9 +391,76 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() ); } -#[test] -fn session_configured_from_thread_response_uses_review_policy_from_response() { - let response = ThreadStartResponse { +#[tokio::test] +async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + sandbox_mode: Some(SandboxMode::DangerFullAccess), + ..Default::default() + }) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config with legacy sandbox override"); + + let start_params = thread_start_params_from_config(&config); + let resume_params = thread_resume_params_from_config(&config, "thread-id".to_string()); + + assert_eq!(config.permissions.active_permission_profile(), None); + assert_eq!( + start_params.sandbox, + Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + ); + assert_eq!(start_params.permissions, None); + assert_eq!( + resume_params.sandbox, + Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) + ); + assert_eq!(resume_params.permissions, None); +} + +#[tokio::test] +async fn session_configured_from_thread_response_uses_review_policy_from_response() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + let response = sample_thread_start_response(); + + let event = session_configured_from_thread_start_response(&response, &config) + .expect("build bootstrap session configured event"); + + assert_eq!(event.approvals_reviewer, ApprovalsReviewer::AutoReview); +} + +#[tokio::test] +async fn session_configured_from_thread_response_uses_permission_profile_from_response() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + let mut response = sample_thread_start_response(); + response.permission_profile = Some(PermissionProfile::Disabled.into()); + + let event = session_configured_from_thread_start_response(&response, &config) + .expect("build bootstrap session configured event"); + + assert_eq!(event.permission_profile, PermissionProfile::Disabled); +} + +fn sample_thread_start_response() -> ThreadStartResponse { + ThreadStartResponse { thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), forked_from_id: None, @@ -426,17 +493,8 @@ fn session_configured_from_thread_response_uses_review_policy_from_response() { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, - permission_profile: Some( - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy(), - ) - .into(), - ), + permission_profile: None, + active_permission_profile: None, reasoning_effort: None, - }; - - let event = session_configured_from_thread_start_response(&response) - .expect("build bootstrap session configured event"); - - assert_eq!(event.approvals_reviewer, ApprovalsReviewer::AutoReview); + } } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 23dcd6733a..f6f6d35f21 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -115,6 +115,7 @@ fn session_configured_produces_thread_started_event() { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/external-agent-sessions/src/detect.rs b/codex-rs/external-agent-sessions/src/detect.rs index 4b7ea9f05b..27613eed18 100644 --- a/codex-rs/external-agent-sessions/src/detect.rs +++ b/codex-rs/external-agent-sessions/src/detect.rs @@ -151,6 +151,61 @@ mod tests { ); } + #[test] + fn detects_ai_title_over_first_user_message() { + let root = TempDir::new().expect("tempdir"); + let external_agent_home = root.path().join(".external"); + let project_root = root.path().join("repo"); + let session_path = write_session( + &external_agent_home, + &project_root, + "session.jsonl", + &[ + record("user", "hello there", project_root.as_path()), + ai_title_record("generated by source app"), + ], + ); + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!( + sessions, + vec![ExternalAgentSessionMigration { + path: session_path, + cwd: project_root, + title: Some("generated by source app".to_string()), + }] + ); + } + + #[test] + fn prefers_custom_title_over_later_ai_title() { + let root = TempDir::new().expect("tempdir"); + let external_agent_home = root.path().join(".external"); + let project_root = root.path().join("repo"); + let session_path = write_session( + &external_agent_home, + &project_root, + "session.jsonl", + &[ + record("user", "hello there", project_root.as_path()), + custom_title_record("custom title"), + ai_title_record("generated title"), + ], + ); + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!( + sessions, + vec![ExternalAgentSessionMigration { + path: session_path, + cwd: project_root, + title: Some("custom title".to_string()), + }] + ); + } + #[test] fn ignores_old_sessions() { let root = TempDir::new().expect("tempdir"); @@ -266,6 +321,13 @@ mod tests { }) } + fn ai_title_record(title: &str) -> JsonValue { + serde_json::json!({ + "type": "ai-title", + "aiTitle": title, + }) + } + fn jsonl(records: &[JsonValue]) -> String { records .iter() diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index b3b793c594..8f34f6ba5a 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -2,9 +2,9 @@ use crate::ConversationMessage; use crate::ImportedExternalAgentSession; use crate::MessageRole; use crate::records::conversation_messages; -use crate::records::custom_title_from_records; use crate::records::project_root_from_records; use crate::records::read_records; +use crate::records::source_title_from_records; use crate::summarize_for_label; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -21,6 +21,8 @@ use codex_utils_output_truncation::approx_tokens_from_byte_count_i64; use std::io; use std::path::Path; +const EXTERNAL_SESSION_IMPORTED_MARKER: &str = ""; + pub fn load_session_for_import(path: &Path) -> io::Result> { let records = read_records(path)?; let Some(cwd) = project_root_from_records(&records) else { @@ -31,7 +33,7 @@ pub fn load_session_for_import(path: &Path) -> io::Result Vec Vec RolloutItem { + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: EXTERNAL_SESSION_IMPORTED_MARKER.to_string(), + phase: None, + memory_citation: None, + })) +} + fn response_item(message: &ConversationMessage) -> ResponseItem { let content = match message.role { MessageRole::Assistant => ContentItem::OutputText { @@ -185,6 +196,7 @@ fn turn_complete_item( #[cfg(test)] mod tests { use super::*; + use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::build_turns_from_rollout_items; use serde_json::Value as JsonValue; use std::path::Path; @@ -213,7 +225,60 @@ mod tests { assert_eq!(turns.len(), 2); assert_eq!(turns[0].items.len(), 2); - assert_eq!(turns[1].items.len(), 1); + assert_eq!(turns[1].items.len(), 2); + assert_eq!( + turns[1].items[1], + ThreadItem::AgentMessage { + id: "item-4".into(), + text: EXTERNAL_SESSION_IMPORTED_MARKER.into(), + phase: None, + memory_citation: None, + } + ); + } + + #[test] + fn adds_import_marker_without_replacing_last_agent_message() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + std::fs::write( + &path, + jsonl(&[ + record("user", "first request", &project_root), + record("assistant", "first answer", &project_root), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + let turns = build_turns_from_rollout_items(&imported.rollout_items); + + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items.last(), + Some(&ThreadItem::AgentMessage { + id: "item-3".into(), + text: EXTERNAL_SESSION_IMPORTED_MARKER.into(), + phase: None, + memory_citation: None, + }) + ); + let last_turn_complete = imported + .rollout_items + .iter() + .rev() + .find_map(|item| match item { + RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => Some(event), + _ => None, + }); + assert_eq!( + last_turn_complete.and_then(|event| event.last_agent_message.as_deref()), + Some("first answer") + ); } #[test] @@ -238,6 +303,51 @@ mod tests { assert_eq!(imported.title.as_deref(), Some("named by source app")); } + #[test] + fn loads_ai_title_for_imported_session() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + std::fs::write( + &path, + jsonl(&[ + record("user", "first request", &project_root), + ai_title_record("generated by source app"), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + + assert_eq!(imported.title.as_deref(), Some("generated by source app")); + } + + #[test] + fn loads_custom_title_over_later_ai_title_for_imported_session() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + std::fs::write( + &path, + jsonl(&[ + record("user", "first request", &project_root), + custom_title_record("named by source app"), + ai_title_record("generated by source app"), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + + assert_eq!(imported.title.as_deref(), Some("named by source app")); + } + #[test] fn emits_token_usage_for_imported_history() { let root = TempDir::new().expect("tempdir"); @@ -287,6 +397,13 @@ mod tests { }) } + fn ai_title_record(title: &str) -> JsonValue { + serde_json::json!({ + "type": "ai-title", + "aiTitle": title, + }) + } + fn jsonl(records: &[JsonValue]) -> String { records .iter() diff --git a/codex-rs/external-agent-sessions/src/lib.rs b/codex-rs/external-agent-sessions/src/lib.rs index 49281893d8..fe9699f0c1 100644 --- a/codex-rs/external-agent-sessions/src/lib.rs +++ b/codex-rs/external-agent-sessions/src/lib.rs @@ -94,6 +94,37 @@ pub fn prepare_pending_session_imports( Ok(pending_session_imports) } +pub fn prepare_validated_session_imports( + codex_home: &Path, + requested_sessions: Vec, +) -> Vec { + requested_sessions + .into_iter() + .filter_map(|session| pending_session_import(codex_home, session)) + .collect() +} + +fn pending_session_import( + codex_home: &Path, + session: ExternalAgentSessionMigration, +) -> Option { + let has_been_imported = match has_current_session_been_imported(codex_home, &session.path) { + Ok(has_been_imported) => has_been_imported, + Err(_) => return None, + }; + if has_been_imported { + return None; + } + let imported_session = match load_importable_session(&session.path) { + Ok(Some(imported_session)) => imported_session, + Ok(None) | Err(_) => return None, + }; + Some(PendingSessionImport { + source_path: session.path, + session: imported_session, + }) +} + fn load_importable_session(path: &Path) -> io::Result> { let Some(imported_session) = load_session_for_import(path)? else { return Ok(None); diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index 93eb07febb..52f0535452 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -13,6 +13,8 @@ use std::path::PathBuf; const NOTE_MAX_LEN: usize = 2_000; const TOOL_RESULT_MAX_LEN: usize = 4_000; +const EXTERNAL_AGENT_TOOL_CALL_TAG: &str = "external_agent_tool_call"; +const EXTERNAL_AGENT_TOOL_RESULT_TAG: &str = "external_agent_tool_result"; pub struct SessionSummary { pub latest_timestamp: i64, @@ -24,6 +26,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { let reader = BufReader::new(file); let mut cwd = None; let mut custom_title = None; + let mut ai_title = None; let mut title = None; let mut latest_timestamp = None; let mut saw_message = false; @@ -46,6 +49,9 @@ pub fn summarize_session(path: &Path) -> io::Result> { if let Some(title) = custom_title_from_record(&record) { custom_title = Some(title.to_string()); } + if let Some(title) = ai_title_from_record(&record) { + ai_title = Some(title.to_string()); + } let Some(message) = conversation_message_from_record(&record) else { continue; }; @@ -73,17 +79,14 @@ pub fn summarize_session(path: &Path) -> io::Result> { migration: ExternalAgentSessionMigration { path: path.to_path_buf(), cwd, - title: custom_title.or(title), + title: custom_title.or(ai_title).or(title), }, })) } -pub(super) fn custom_title_from_records(records: &[JsonValue]) -> Option { - records - .iter() - .filter_map(custom_title_from_record) - .next_back() - .map(ToOwned::to_owned) +pub(super) fn source_title_from_records(records: &[JsonValue]) -> Option { + latest_title_from_records(records, custom_title_from_record) + .or_else(|| latest_title_from_records(records, ai_title_from_record)) } pub(super) fn read_records(path: &Path) -> io::Result> { @@ -120,9 +123,28 @@ pub(super) fn conversation_messages(records: &[JsonValue]) -> Vec( + records: &'a [JsonValue], + title_from_record: impl Fn(&'a JsonValue) -> Option<&'a str>, +) -> Option { + records + .iter() + .filter_map(title_from_record) + .next_back() + .map(ToOwned::to_owned) +} + fn custom_title_from_record(record: &JsonValue) -> Option<&str> { - (record.get("type").and_then(JsonValue::as_str) == Some("custom-title")) - .then(|| record.get("customTitle").and_then(JsonValue::as_str)) + title_from_record(record, "custom-title", "customTitle") +} + +fn ai_title_from_record(record: &JsonValue) -> Option<&str> { + title_from_record(record, "ai-title", "aiTitle") +} + +fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str) -> Option<&'a str> { + (record.get("type").and_then(JsonValue::as_str) == Some(record_type)) + .then(|| record.get(field).and_then(JsonValue::as_str)) .flatten() .map(str::trim) .filter(|title| !title.is_empty()) @@ -232,7 +254,7 @@ fn tool_call_note(block: &JsonValue) -> String { .get("name") .and_then(JsonValue::as_str) .unwrap_or("unknown"); - let mut lines = vec![format!("[external tool call: {name}]")]; + let mut lines = vec![format!("[{EXTERNAL_AGENT_TOOL_CALL_TAG}: {name}]")]; if let Some(input) = block.get("input").and_then(JsonValue::as_object) { if let Some(description) = input.get("description").and_then(JsonValue::as_str) { lines.push(format!("description: {description}")); @@ -259,20 +281,24 @@ fn tool_call_note(block: &JsonValue) -> String { truncate(&input.to_string(), NOTE_MAX_LEN) )); } + lines.push(format!("[/{EXTERNAL_AGENT_TOOL_CALL_TAG}]")); lines.join("\n") } fn tool_result_note(block: &JsonValue) -> String { let label = if block.get("is_error").and_then(JsonValue::as_bool) == Some(true) { - "[external tool result: error]" + format!("[{EXTERNAL_AGENT_TOOL_RESULT_TAG}: error]") } else { - "[external tool result]" + format!("[{EXTERNAL_AGENT_TOOL_RESULT_TAG}]") }; let text = tool_result_text(block.get("content")); if text.is_empty() { - label.to_string() + format!("{label}\n[/{EXTERNAL_AGENT_TOOL_RESULT_TAG}]") } else { - format!("{label}\n{}", truncate(&text, TOOL_RESULT_MAX_LEN)) + format!( + "{label}\n{}\n[/{EXTERNAL_AGENT_TOOL_RESULT_TAG}]", + truncate(&text, TOOL_RESULT_MAX_LEN) + ) } } @@ -294,3 +320,59 @@ fn parse_timestamp(timestamp: &str) -> Option { .ok() .map(|value| value.timestamp()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_tool_use_blocks_to_bounded_external_agent_tags() { + let block = serde_json::json!({ + "type": "tool_use", + "name": "Bash", + "input": { + "description": "Check repo status", + "command": "git status --short" + } + }); + + assert_eq!( + tool_call_note(&block), + "[external_agent_tool_call: Bash]\n\ + description: Check repo status\n\ + command: git status --short\n\ + [/external_agent_tool_call]" + ); + } + + #[test] + fn converts_tool_result_blocks_to_bounded_external_agent_tags() { + let block = serde_json::json!({ + "type": "tool_result", + "content": "codex-rs/external-agent-sessions/src/records.rs" + }); + + assert_eq!( + tool_result_note(&block), + "[external_agent_tool_result]\n\ + codex-rs/external-agent-sessions/src/records.rs\n\ + [/external_agent_tool_result]" + ); + } + + #[test] + fn converts_error_tool_result_blocks_to_bounded_external_agent_tags() { + let block = serde_json::json!({ + "type": "tool_result", + "is_error": true, + "content": "command failed" + }); + + assert_eq!( + tool_result_note(&block), + "[external_agent_tool_result: error]\n\ + command failed\n\ + [/external_agent_tool_result]" + ); + } +} diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index fd489db505..21c504bd8d 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -31,3 +31,18 @@ impl FeatureConfig for MultiAgentV2ConfigToml { self.enabled } } + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AppsMcpPathOverrideConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +impl FeatureConfig for AppsMcpPathOverrideConfigToml { + fn enabled(&self) -> Option { + self.enabled.or(self.path.as_ref().map(|_| true)) + } +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index d997cc771f..e2f859c22f 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -16,6 +16,7 @@ use toml::Table; mod feature_configs; mod legacy; +pub use feature_configs::AppsMcpPathOverrideConfigToml; pub use feature_configs::MultiAgentV2ConfigToml; use legacy::LegacyFeatureToggles; pub use legacy::legacy_feature_keys; @@ -149,6 +150,8 @@ pub enum Feature { Apps, /// Enable MCP apps. EnableMcpApps, + /// Use the new path for the built-in apps MCP server. + AppsMcpPathOverride, /// Enable the tool_search tool for apps. ToolSearch, /// Always defer MCP tools behind tool_search instead of exposing small sets directly. @@ -557,6 +560,8 @@ pub fn is_known_feature_key(key: &str) -> bool { pub struct FeaturesToml { #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub apps_mcp_path_override: Option>, /// Boolean feature toggles keyed by canonical or legacy feature name. #[serde(flatten)] entries: BTreeMap, @@ -575,6 +580,13 @@ impl FeaturesToml { if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } + if let Some(enabled) = self + .apps_mcp_path_override + .as_ref() + .and_then(FeatureToml::enabled) + { + entries.insert(Feature::AppsMcpPathOverride.key().to_string(), enabled); + } entries } } @@ -848,6 +860,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::AppsMcpPathOverride, + key: "apps_mcp_path_override", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ToolSearch, key: "tool_search", diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index f2e195bb94..0da34f0e2d 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -18,12 +18,15 @@ use serde::Deserialize; use std::collections::HashMap; use super::ConfiguredHandler; +use super::HookListEntry; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; +use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; pub(crate) struct DiscoveryResult { pub handlers: Vec, + pub hook_entries: Vec, pub warnings: Vec, } @@ -33,6 +36,7 @@ struct HookHandlerSource<'a> { is_managed: bool, source: HookSource, env: HashMap, + plugin_id: Option, } pub(crate) fn discover_handlers( @@ -40,93 +44,77 @@ pub(crate) fn discover_handlers( plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, ) -> DiscoveryResult { - let Some(config_layer_stack) = config_layer_stack else { - let mut handlers = Vec::new(); - let mut warnings = plugin_hook_load_warnings; - let mut display_order = 0_i64; - append_plugin_hook_sources( - &mut handlers, - &mut warnings, - &mut display_order, - plugin_hook_sources, - ); - return DiscoveryResult { handlers, warnings }; - }; - let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); let mut warnings = plugin_hook_load_warnings; let mut display_order = 0_i64; - append_managed_requirement_handlers( - &mut handlers, - &mut warnings, - &mut display_order, - config_layer_stack, - ); + if let Some(config_layer_stack) = config_layer_stack { + append_managed_requirement_handlers( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + config_layer_stack, + ); - for layer in config_layer_stack.get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ false, - ) { - let hook_source = hook_source_for_config_layer_source(&layer.name); - let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); - let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + let hook_source = hook_source_for_config_layer_source(&layer.name); + let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); + let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); - if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) = - (&json_hooks, &toml_hooks) - && !json_events.is_empty() - && !toml_events.is_empty() - { - warnings.push(format!( - "loading hooks from both {} and {}; prefer a single representation for this layer", - json_source_path.display(), - toml_source_path.display() - )); - } + if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) = + (&json_hooks, &toml_hooks) + && !json_events.is_empty() + && !toml_events.is_empty() + { + warnings.push(format!( + "loading hooks from both {} and {}; prefer a single representation for this layer", + json_source_path.display(), + toml_source_path.display() + )); + } - if let Some((source_path, hook_events)) = json_hooks { - append_hook_events( - &mut handlers, - &mut warnings, - &mut display_order, - HookHandlerSource { - path: &source_path, - is_managed: false, - source: hook_source, - env: HashMap::new(), - }, - hook_events, - ); - } - - if let Some((source_path, hook_events)) = toml_hooks { - append_hook_events( - &mut handlers, - &mut warnings, - &mut display_order, - HookHandlerSource { - path: &source_path, - is_managed: false, - source: hook_source, - env: HashMap::new(), - }, - hook_events, - ); + for (source_path, hook_events) in [json_hooks, toml_hooks].into_iter().flatten() { + append_hook_events( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + HookHandlerSource { + path: &source_path, + is_managed: false, + source: hook_source, + env: HashMap::new(), + plugin_id: None, + }, + hook_events, + ); + } } } append_plugin_hook_sources( &mut handlers, + &mut hook_entries, &mut warnings, &mut display_order, plugin_hook_sources, ); - DiscoveryResult { handlers, warnings } + DiscoveryResult { + handlers, + hook_entries, + warnings, + } } fn append_managed_requirement_handlers( handlers: &mut Vec, + hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, config_layer_stack: &ConfigLayerStack, @@ -141,6 +129,7 @@ fn append_managed_requirement_handlers( }; append_hook_events( handlers, + hook_entries, warnings, display_order, HookHandlerSource { @@ -148,6 +137,7 @@ fn append_managed_requirement_handlers( is_managed: true, source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), env: HashMap::new(), + plugin_id: None, }, managed_hooks.get().hooks.clone(), ); @@ -155,6 +145,7 @@ fn append_managed_requirement_handlers( fn append_plugin_hook_sources( handlers: &mut Vec, + hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, plugin_hook_sources: Vec, @@ -163,6 +154,7 @@ fn append_plugin_hook_sources( for source in plugin_hook_sources { let PluginHookSource { plugin_root, + plugin_id, plugin_data_root, source_path, hooks, @@ -177,8 +169,10 @@ fn append_plugin_hook_sources( env.insert("PLUGIN_DATA".to_string(), plugin_data_root_value.clone()); // For OOTB compat with existing plugins that use this env var. env.insert("CLAUDE_PLUGIN_DATA".to_string(), plugin_data_root_value); + let plugin_id = plugin_id.as_key(); append_hook_events( handlers, + hook_entries, warnings, display_order, HookHandlerSource { @@ -186,6 +180,7 @@ fn append_plugin_hook_sources( is_managed: false, source: HookSource::Plugin, env, + plugin_id: Some(plugin_id), }, hooks, ); @@ -330,6 +325,7 @@ fn synthetic_layer_path(path: &str) -> AbsolutePathBuf { fn append_hook_events( handlers: &mut Vec, + hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, source: HookHandlerSource<'_>, @@ -338,6 +334,7 @@ fn append_hook_events( for (event_name, groups) in hook_events.into_matcher_groups() { append_matcher_groups( handlers, + hook_entries, warnings, display_order, source.clone(), @@ -349,6 +346,7 @@ fn append_hook_events( fn append_matcher_groups( handlers: &mut Vec, + hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, source: HookHandlerSource<'_>, @@ -356,85 +354,78 @@ fn append_matcher_groups( groups: Vec, ) { for group in groups { - append_group_handlers( - handlers, - warnings, - display_order, - source.clone(), - event_name, - matcher_pattern_for_event(event_name, group.matcher.as_deref()), - group.hooks, - ); - } -} + let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref()); + if let Some(matcher) = matcher + && let Err(err) = validate_matcher_pattern(matcher) + { + warnings.push(format!( + "invalid matcher {matcher:?} in {}: {err}", + source.path.display() + )); + continue; + } -fn append_group_handlers( - handlers: &mut Vec, - warnings: &mut Vec, - display_order: &mut i64, - source: HookHandlerSource<'_>, - event_name: codex_protocol::protocol::HookEventName, - matcher: Option<&str>, - group_handlers: Vec, -) { - if let Some(matcher) = matcher - && let Err(err) = validate_matcher_pattern(matcher) - { - warnings.push(format!( - "invalid matcher {matcher:?} in {}: {err}", - source.path.display() - )); - return; - } - - for handler in group_handlers { - match handler { - HookHandlerConfig::Command { - command, - timeout_sec, - r#async, - status_message, - } => { - if r#async { - warnings.push(format!( - "skipping async hook in {}: async hooks are not supported yet", - source.path.display() - )); - continue; - } - if command.trim().is_empty() { - warnings.push(format!( - "skipping empty hook command in {}", - source.path.display() - )); - continue; - } - let command = source.env.iter().fold(command, |command, (key, value)| { - command.replace(&format!("${{{key}}}"), value) - }); - let timeout_sec = timeout_sec.unwrap_or(600).max(1); - handlers.push(ConfiguredHandler { - event_name, - is_managed: source.is_managed, - matcher: matcher.map(ToOwned::to_owned), + for handler in group.hooks { + match handler { + HookHandlerConfig::Command { command, timeout_sec, + r#async, status_message, - source_path: source.path.clone(), - source: source.source, - display_order: *display_order, - env: source.env.clone(), - }); - *display_order += 1; + } => { + if r#async { + warnings.push(format!( + "skipping async hook in {}: async hooks are not supported yet", + source.path.display() + )); + continue; + } + if command.trim().is_empty() { + warnings.push(format!( + "skipping empty hook command in {}", + source.path.display() + )); + continue; + } + let command = source.env.iter().fold(command, |command, (key, value)| { + command.replace(&format!("${{{key}}}"), value) + }); + let timeout_sec = timeout_sec.unwrap_or(600).max(1); + hook_entries.push(HookListEntry { + event_name, + handler_type: HookHandlerType::Command, + matcher: matcher.map(ToOwned::to_owned), + command: Some(command.clone()), + timeout_sec, + status_message: status_message.clone(), + source_path: source.path.clone(), + source: source.source, + plugin_id: source.plugin_id.clone(), + display_order: *display_order, + }); + handlers.push(ConfiguredHandler { + event_name, + is_managed: source.is_managed, + matcher: matcher.map(ToOwned::to_owned), + command, + timeout_sec, + status_message, + source_path: source.path.clone(), + source: source.source, + display_order: *display_order, + env: source.env.clone(), + }); + *display_order += 1; + } + HookHandlerConfig::Prompt {} => warnings.push(format!( + "skipping prompt hook in {}: prompt hooks are not supported yet", + source.path.display() + )), + HookHandlerConfig::Agent {} => warnings.push(format!( + "skipping agent hook in {}: agent hooks are not supported yet", + source.path.display() + )), } - HookHandlerConfig::Prompt {} => warnings.push(format!( - "skipping prompt hook in {}: prompt hooks are not supported yet", - source.path.display() - )), - HookHandlerConfig::Agent {} => warnings.push(format!( - "skipping agent hook in {}: agent hooks are not supported yet", - source.path.display() - )), } } } @@ -498,6 +489,7 @@ mod tests { is_managed: false, source: hook_source(), env: std::collections::HashMap::new(), + plugin_id: None, } } @@ -522,6 +514,7 @@ mod tests { append_matcher_groups( &mut handlers, + &mut Vec::new(), &mut warnings, &mut display_order, hook_handler_source(&source_path), @@ -556,6 +549,7 @@ mod tests { append_matcher_groups( &mut handlers, + &mut Vec::new(), &mut warnings, &mut display_order, hook_handler_source(&source_path), @@ -590,6 +584,7 @@ mod tests { append_matcher_groups( &mut handlers, + &mut Vec::new(), &mut warnings, &mut display_order, hook_handler_source(&source_path), @@ -611,6 +606,7 @@ mod tests { append_matcher_groups( &mut handlers, + &mut Vec::new(), &mut warnings, &mut display_order, hook_handler_source(&source_path), diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 89daf501ca..83482d5006 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -8,6 +8,8 @@ use std::collections::HashMap; use codex_config::ConfigLayerStack; use codex_plugin::PluginHookSource; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::AbsolutePathBuf; @@ -67,6 +69,20 @@ impl ConfiguredHandler { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookListEntry { + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub matcher: Option, + pub command: Option, + pub timeout_sec: u64, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source: HookSource, + pub plugin_id: Option, + pub display_order: i64, +} + #[derive(Clone)] pub(crate) struct ClaudeHooksEngine { handlers: Vec, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index b2958e711e..1b10a33895 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -5,6 +5,7 @@ mod registry; mod schema; mod types; +pub use engine::HookListEntry; /// Hook event names as they appear in hooks JSON and config files. pub const HOOK_EVENT_NAMES: [&str; 6] = [ "PreToolUse", @@ -26,7 +27,6 @@ pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 4] = [ "PostToolUse", "SessionStart", ]; - pub use events::permission_request::PermissionRequestDecision; pub use events::permission_request::PermissionRequestOutcome; pub use events::permission_request::PermissionRequestRequest; @@ -43,9 +43,11 @@ pub use events::user_prompt_submit::UserPromptSubmitOutcome; pub use events::user_prompt_submit::UserPromptSubmitRequest; pub use legacy_notify::legacy_notify_json; pub use legacy_notify::notify_hook; +pub use registry::HookListOutcome; pub use registry::Hooks; pub use registry::HooksConfig; pub use registry::command_from_argv; +pub use registry::list_hooks; pub use schema::write_schema_fixtures; pub use types::Hook; pub use types::HookEvent; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 7dd93213a1..ae80015729 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -4,6 +4,7 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; +use crate::engine::HookListEntry; use crate::events::permission_request::PermissionRequestOutcome; use crate::events::permission_request::PermissionRequestRequest; use crate::events::post_tool_use::PostToolUseOutcome; @@ -32,6 +33,12 @@ pub struct HooksConfig { pub shell_args: Vec, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HookListOutcome { + pub hooks: Vec, + pub warnings: Vec, +} + #[derive(Clone)] pub struct Hooks { after_agent: Vec, @@ -173,6 +180,22 @@ impl Hooks { } } +pub fn list_hooks(config: HooksConfig) -> HookListOutcome { + if !config.feature_enabled { + return HookListOutcome::default(); + } + + let discovered = crate::engine::discovery::discover_handlers( + config.config_layer_stack.as_ref(), + config.plugin_hook_sources, + config.plugin_hook_load_warnings, + ); + HookListOutcome { + hooks: discovered.hook_entries, + warnings: discovered.warnings, + } +} + pub fn command_from_argv(argv: &[String]) -> Option { let (program, args) = argv.split_first()?; if program.is_empty() { diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index ff40f3663d..22e7d72531 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -3,8 +3,8 @@ //! This module mirrors the semantics used by the macOS Seatbelt sandbox: //! - the filesystem is read-only by default, //! - explicit writable roots are layered on top, and -//! - sensitive subpaths such as `.git` and `.codex` remain read-only even when -//! their parent root is writable. +//! - sensitive subpaths such as `.git`, `.agents`, and `.codex` remain +//! read-only even when their parent root is writable. //! //! The overall Linux sandbox is composed of: //! - seccomp + `PR_SET_NO_NEW_PRIVS` applied in-process, and @@ -15,15 +15,18 @@ use std::collections::HashSet; use std::ffi::OsString; use std::fs; use std::fs::File; +use std::fs::Metadata; use std::io; use std::os::fd::AsRawFd; use std::os::unix::ffi::OsStringExt; +use std::os::unix::fs::MetadataExt; use std::path::Path; use std::path::PathBuf; use std::process::Command; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; +use codex_protocol::permissions::is_protected_metadata_name; use codex_protocol::protocol::FileSystemAccessMode; use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxPolicy; @@ -104,6 +107,121 @@ impl BwrapNetworkMode { pub(crate) struct BwrapArgs { pub args: Vec, pub preserved_files: Vec, + pub synthetic_mount_targets: Vec, + pub protected_create_targets: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FileIdentity { + dev: u64, + ino: u64, +} + +impl FileIdentity { + fn from_metadata(metadata: &Metadata) -> Self { + Self { + dev: metadata.dev(), + ino: metadata.ino(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SyntheticMountTargetKind { + EmptyFile, + EmptyDirectory, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SyntheticMountTarget { + path: PathBuf, + kind: SyntheticMountTargetKind, + // If an empty metadata path was already present, remember its inode so + // cleanup does not delete a real pre-existing file or directory. + pre_existing_path: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ProtectedCreateTarget { + path: PathBuf, +} + +impl ProtectedCreateTarget { + pub(crate) fn missing(path: &Path) -> Self { + Self { + path: path.to_path_buf(), + } + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +impl SyntheticMountTarget { + pub(crate) fn missing(path: &Path) -> Self { + Self { + path: path.to_path_buf(), + kind: SyntheticMountTargetKind::EmptyFile, + pre_existing_path: None, + } + } + + pub(crate) fn missing_empty_directory(path: &Path) -> Self { + Self { + path: path.to_path_buf(), + kind: SyntheticMountTargetKind::EmptyDirectory, + pre_existing_path: None, + } + } + + pub(crate) fn existing_empty_file(path: &Path, metadata: &Metadata) -> Self { + Self { + path: path.to_path_buf(), + kind: SyntheticMountTargetKind::EmptyFile, + pre_existing_path: Some(FileIdentity::from_metadata(metadata)), + } + } + + fn existing_empty_directory(path: &Path, metadata: &Metadata) -> Self { + Self { + path: path.to_path_buf(), + kind: SyntheticMountTargetKind::EmptyDirectory, + pre_existing_path: Some(FileIdentity::from_metadata(metadata)), + } + } + + pub(crate) fn preserves_pre_existing_path(&self) -> bool { + self.pre_existing_path.is_some() + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } + + pub(crate) fn kind(&self) -> SyntheticMountTargetKind { + self.kind + } + + pub(crate) fn should_remove_after_bwrap(&self, metadata: &Metadata) -> bool { + match self.kind { + SyntheticMountTargetKind::EmptyFile => { + if !metadata.file_type().is_file() || metadata.len() != 0 { + return false; + } + } + SyntheticMountTargetKind::EmptyDirectory => { + if !metadata.file_type().is_dir() { + return false; + } + } + } + + match self.pre_existing_path { + Some(pre_existing_path) => pre_existing_path != FileIdentity::from_metadata(metadata), + None => true, + } + } } /// Wrap a command with bubblewrap so the filesystem is read-only by default, @@ -129,6 +247,8 @@ pub(crate) fn create_bwrap_command_args( Ok(BwrapArgs { args: command, preserved_files: Vec::new(), + synthetic_mount_targets: Vec::new(), + protected_create_targets: Vec::new(), }) } else { Ok(create_bwrap_flags_full_filesystem(command, options)) @@ -168,6 +288,8 @@ fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOption BwrapArgs { args, preserved_files: Vec::new(), + synthetic_mount_targets: Vec::new(), + protected_create_targets: Vec::new(), } } @@ -182,6 +304,8 @@ fn create_bwrap_flags( let BwrapArgs { args: filesystem_args, preserved_files, + synthetic_mount_targets, + protected_create_targets, } = create_filesystem_args( file_system_sandbox_policy, sandbox_policy_cwd, @@ -219,6 +343,8 @@ fn create_bwrap_flags( Ok(BwrapArgs { args, preserved_files, + synthetic_mount_targets, + protected_create_targets, }) } @@ -277,14 +403,18 @@ fn create_filesystem_args( else { return None; }; - // Missing `.codex` remains protected so first-time project config - // creation still goes through the protected-path approval flow. - // Only the automatic repo-metadata read masks are skipped here: - // user-authored `read` rules for other subpaths and `none` rules - // should keep their normal bwrap behavior, which can mask the - // first missing component to prevent creation under writable roots. + // Automatic repo-metadata read masks are skipped here so the + // metadata handling below can apply the root-scoped + // protection consistently for `.git`, `.agents`, and `.codex`. + // User-authored `read` rules for other subpaths and `none` + // rules should keep their normal bwrap behavior, which can mask + // the first missing component to prevent creation under writable + // roots. let project_subpath = subpath.as_path(); - if project_subpath != Path::new(".git") && project_subpath != Path::new(".agents") { + if project_subpath != Path::new(".git") + && project_subpath != Path::new(".agents") + && project_subpath != Path::new(".codex") + { return None; } let resolved = AbsolutePathBuf::resolve_path_against_base(subpath, cwd); @@ -307,7 +437,7 @@ fn create_filesystem_args( unreadable_roots.sort(); unreadable_roots.dedup(); - let mut args = if file_system_sandbox_policy.has_full_disk_read_access() { + let args = if file_system_sandbox_policy.has_full_disk_read_access() { // Read-only root, then mount a minimal device tree. // In bubblewrap (`bubblewrap.c`, `SETUP_MOUNT_DEV`), `--dev /dev` // creates the standard minimal nodes: null, zero, full, random, @@ -380,7 +510,12 @@ fn create_filesystem_args( args }; - let mut preserved_files = Vec::new(); + let mut bwrap_args = BwrapArgs { + args, + preserved_files: Vec::new(), + synthetic_mount_targets: Vec::new(), + protected_create_targets: Vec::new(), + }; let mut allowed_write_paths = Vec::with_capacity(writable_roots.len()); for writable_root in &writable_roots { let root = writable_root.root.as_path(); @@ -411,12 +546,7 @@ fn create_filesystem_args( unreadable_ancestors_of_writable_roots.sort_by_key(|path| path_depth(path)); for unreadable_root in &unreadable_ancestors_of_writable_roots { - append_unreadable_root_args( - &mut args, - &mut preserved_files, - unreadable_root, - &allowed_write_paths, - )?; + append_unreadable_root_args(&mut bwrap_args, unreadable_root, &allowed_write_paths)?; } for writable_root in &sorted_writable_roots { @@ -430,13 +560,13 @@ fn create_filesystem_args( .filter(|unreadable_root| root.starts_with(unreadable_root)) .max_by_key(|unreadable_root| path_depth(unreadable_root)) { - append_mount_target_parent_dir_args(&mut args, root, masking_root); + append_mount_target_parent_dir_args(&mut bwrap_args.args, root, masking_root); } let mount_root = symlink_target.as_deref().unwrap_or(root); - args.push("--bind".to_string()); - args.push(path_to_string(mount_root)); - args.push(path_to_string(mount_root)); + bwrap_args.args.push("--bind".to_string()); + bwrap_args.args.push(path_to_string(mount_root)); + bwrap_args.args.push(path_to_string(mount_root)); let mut read_only_subpaths: Vec = writable_root .read_only_subpaths @@ -445,12 +575,26 @@ fn create_filesystem_args( .filter(|path| !unreadable_paths.contains(path)) .filter(|path| !missing_auto_metadata_read_only_project_root_subpaths.contains(path)) .collect(); + let protected_metadata_names = writable_root.protected_metadata_names.clone(); + append_metadata_path_masks_for_writable_root( + &mut read_only_subpaths, + root, + mount_root, + &protected_metadata_names, + ); if let Some(target) = &symlink_target { read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target); } + append_protected_create_targets_for_writable_root( + &mut bwrap_args, + &protected_metadata_names, + root, + symlink_target.as_deref(), + &read_only_subpaths, + ); read_only_subpaths.sort_by_key(|path| path_depth(path)); for subpath in read_only_subpaths { - append_read_only_subpath_args(&mut args, &subpath, &allowed_write_paths)?; + append_read_only_subpath_args(&mut bwrap_args, &subpath, &allowed_write_paths)?; } let mut nested_unreadable_roots: Vec = unreadable_roots .iter() @@ -463,12 +607,7 @@ fn create_filesystem_args( } nested_unreadable_roots.sort_by_key(|path| path_depth(path)); for unreadable_root in nested_unreadable_roots { - append_unreadable_root_args( - &mut args, - &mut preserved_files, - &unreadable_root, - &allowed_write_paths, - )?; + append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?; } } @@ -484,18 +623,78 @@ fn create_filesystem_args( .collect(); rootless_unreadable_roots.sort_by_key(|path| path_depth(path)); for unreadable_root in rootless_unreadable_roots { - append_unreadable_root_args( - &mut args, - &mut preserved_files, - &unreadable_root, - &allowed_write_paths, - )?; + append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?; } - Ok(BwrapArgs { - args, - preserved_files, - }) + Ok(bwrap_args) +} + +fn append_protected_create_targets_for_writable_root( + bwrap_args: &mut BwrapArgs, + protected_metadata_names: &[String], + root: &Path, + symlink_target: Option<&Path>, + read_only_subpaths: &[PathBuf], +) { + for name in protected_metadata_names { + let mut path = root.join(name); + if let Some(target) = symlink_target + && let Ok(relative_path) = path.strip_prefix(root) + { + path = target.join(relative_path); + } + if read_only_subpaths.iter().any(|subpath| subpath == &path) || path.exists() { + continue; + } + bwrap_args + .protected_create_targets + .push(ProtectedCreateTarget::missing(&path)); + } +} + +fn append_metadata_path_masks_for_writable_root( + read_only_subpaths: &mut Vec, + root: &Path, + mount_root: &Path, + protected_metadata_names: &[String], +) { + for name in protected_metadata_names { + let path = root.join(name); + if should_leave_missing_git_for_parent_repo_discovery(mount_root, name) { + continue; + } + if !read_only_subpaths.iter().any(|subpath| subpath == &path) { + read_only_subpaths.push(path); + } + } +} + +fn should_leave_missing_git_for_parent_repo_discovery(mount_root: &Path, name: &str) -> bool { + let path = mount_root.join(name); + name == ".git" + && matches!( + path.symlink_metadata(), + Err(err) if err.kind() == io::ErrorKind::NotFound + ) + && mount_root + .ancestors() + .skip(1) + .any(ancestor_has_git_metadata) +} + +fn ancestor_has_git_metadata(ancestor: &Path) -> bool { + let git_path = ancestor.join(".git"); + let Ok(metadata) = git_path.symlink_metadata() else { + return false; + }; + if metadata.is_dir() { + return git_path.join("HEAD").symlink_metadata().is_ok(); + } + if metadata.is_file() { + return fs::read_to_string(git_path) + .is_ok_and(|contents| contents.trim_start().starts_with("gitdir:")); + } + false } fn expand_unreadable_globs_with_ripgrep( @@ -820,7 +1019,7 @@ fn append_mount_target_parent_dir_args(args: &mut Vec, mount_target: &Pa } fn append_read_only_subpath_args( - args: &mut Vec, + bwrap_args: &mut BwrapArgs, subpath: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { @@ -838,28 +1037,107 @@ fn append_read_only_subpath_args( ))); } + if let Some(metadata) = transient_empty_metadata_path(subpath) + && is_within_allowed_write_paths(subpath, allowed_write_paths) + { + // Another concurrent bwrap setup can leave an empty mount target at + // a missing metadata path. Treat it like the missing case instead of + // binding that transient host path as the stable source. + match metadata { + EmptyProtectedMetadataPath::File(metadata) => { + append_existing_empty_file_bind_data_args(bwrap_args, subpath, &metadata)?; + } + EmptyProtectedMetadataPath::Directory(metadata) => { + append_existing_empty_directory_args(bwrap_args, subpath, &metadata); + } + } + return Ok(()); + } + if !subpath.exists() { if let Some(first_missing_component) = find_first_non_existent_component(subpath) && is_within_allowed_write_paths(&first_missing_component, allowed_write_paths) { - args.push("--ro-bind".to_string()); - args.push("/dev/null".to_string()); - args.push(path_to_string(&first_missing_component)); + append_missing_read_only_subpath_args(bwrap_args, &first_missing_component)?; } return Ok(()); } if is_within_allowed_write_paths(subpath, allowed_write_paths) { - args.push("--ro-bind".to_string()); - args.push(path_to_string(subpath)); - args.push(path_to_string(subpath)); + bwrap_args.args.push("--ro-bind".to_string()); + bwrap_args.args.push(path_to_string(subpath)); + bwrap_args.args.push(path_to_string(subpath)); } Ok(()) } +fn append_empty_file_bind_data_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> { + if bwrap_args.preserved_files.is_empty() { + bwrap_args.preserved_files.push(File::open("/dev/null")?); + } + let null_fd = bwrap_args.preserved_files[0].as_raw_fd().to_string(); + bwrap_args.args.push("--ro-bind-data".to_string()); + bwrap_args.args.push(null_fd); + bwrap_args.args.push(path_to_string(path)); + Ok(()) +} + +fn append_empty_directory_args(bwrap_args: &mut BwrapArgs, path: &Path) { + bwrap_args.args.push("--perms".to_string()); + bwrap_args.args.push("555".to_string()); + bwrap_args.args.push("--tmpfs".to_string()); + bwrap_args.args.push(path_to_string(path)); + bwrap_args.args.push("--remount-ro".to_string()); + bwrap_args.args.push(path_to_string(path)); +} + +fn append_missing_read_only_subpath_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> { + if path.file_name().is_some_and(is_protected_metadata_name) { + append_empty_directory_args(bwrap_args, path); + bwrap_args + .synthetic_mount_targets + .push(SyntheticMountTarget::missing_empty_directory(path)); + return Ok(()); + } + + append_missing_empty_file_bind_data_args(bwrap_args, path) +} + +fn append_missing_empty_file_bind_data_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> { + append_empty_file_bind_data_args(bwrap_args, path)?; + bwrap_args + .synthetic_mount_targets + .push(SyntheticMountTarget::missing(path)); + Ok(()) +} + +fn append_existing_empty_file_bind_data_args( + bwrap_args: &mut BwrapArgs, + path: &Path, + metadata: &Metadata, +) -> Result<()> { + append_empty_file_bind_data_args(bwrap_args, path)?; + bwrap_args + .synthetic_mount_targets + .push(SyntheticMountTarget::existing_empty_file(path, metadata)); + Ok(()) +} + +fn append_existing_empty_directory_args( + bwrap_args: &mut BwrapArgs, + path: &Path, + metadata: &Metadata, +) { + append_empty_directory_args(bwrap_args, path); + bwrap_args + .synthetic_mount_targets + .push(SyntheticMountTarget::existing_empty_directory( + path, metadata, + )); +} + fn append_unreadable_root_args( - args: &mut Vec, - preserved_files: &mut Vec, + bwrap_args: &mut BwrapArgs, unreadable_root: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { @@ -884,24 +1162,16 @@ fn append_unreadable_root_args( if let Some(first_missing_component) = find_first_non_existent_component(unreadable_root) && is_within_allowed_write_paths(&first_missing_component, allowed_write_paths) { - args.push("--ro-bind".to_string()); - args.push("/dev/null".to_string()); - args.push(path_to_string(&first_missing_component)); + append_missing_empty_file_bind_data_args(bwrap_args, &first_missing_component)?; } return Ok(()); } - append_existing_unreadable_path_args( - args, - preserved_files, - unreadable_root, - allowed_write_paths, - ) + append_existing_unreadable_path_args(bwrap_args, unreadable_root, allowed_write_paths) } fn append_existing_unreadable_path_args( - args: &mut Vec, - preserved_files: &mut Vec, + bwrap_args: &mut BwrapArgs, unreadable_root: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { @@ -911,40 +1181,37 @@ fn append_existing_unreadable_path_args( .map(PathBuf::as_path) .filter(|path| *path != unreadable_root && path.starts_with(unreadable_root)) .collect(); - args.push("--perms".to_string()); + bwrap_args.args.push("--perms".to_string()); // Execute-only perms let the process traverse into explicitly // re-opened writable descendants while still hiding the denied // directory contents. Plain denied directories with no writable child // mounts stay at `000`. - args.push(if writable_descendants.is_empty() { + bwrap_args.args.push(if writable_descendants.is_empty() { "000".to_string() } else { "111".to_string() }); - args.push("--tmpfs".to_string()); - args.push(path_to_string(unreadable_root)); + bwrap_args.args.push("--tmpfs".to_string()); + bwrap_args.args.push(path_to_string(unreadable_root)); // Recreate any writable descendants inside the tmpfs before remounting // the denied parent read-only. Otherwise bubblewrap cannot mkdir the // nested mount targets after the parent has been frozen. writable_descendants.sort_by_key(|path| path_depth(path)); for writable_descendant in writable_descendants { - append_mount_target_parent_dir_args(args, writable_descendant, unreadable_root); + append_mount_target_parent_dir_args( + &mut bwrap_args.args, + writable_descendant, + unreadable_root, + ); } - args.push("--remount-ro".to_string()); - args.push(path_to_string(unreadable_root)); + bwrap_args.args.push("--remount-ro".to_string()); + bwrap_args.args.push(path_to_string(unreadable_root)); return Ok(()); } - if preserved_files.is_empty() { - preserved_files.push(File::open("/dev/null")?); - } - let null_fd = preserved_files[0].as_raw_fd().to_string(); - args.push("--perms".to_string()); - args.push("000".to_string()); - args.push("--ro-bind-data".to_string()); - args.push(null_fd); - args.push(path_to_string(unreadable_root)); - Ok(()) + bwrap_args.args.push("--perms".to_string()); + bwrap_args.args.push("000".to_string()); + append_empty_file_bind_data_args(bwrap_args, unreadable_root) } /// Returns true when `path` is under any allowed writable root. @@ -954,6 +1221,35 @@ fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) - .any(|root| path.starts_with(root)) } +enum EmptyProtectedMetadataPath { + File(Metadata), + Directory(Metadata), +} + +fn transient_empty_metadata_path(path: &Path) -> Option { + if !path.file_name().is_some_and(is_protected_metadata_name) { + return None; + } + + let metadata = fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_file() && metadata.len() == 0 { + return Some(EmptyProtectedMetadataPath::File(metadata)); + } + + if metadata.file_type().is_dir() && directory_is_empty(path) { + return Some(EmptyProtectedMetadataPath::Directory(metadata)); + } + + None +} + +fn directory_is_empty(path: &Path) -> bool { + let Ok(mut entries) = fs::read_dir(path) else { + return false; + }; + entries.next().is_none() +} + fn first_writable_symlink_component_in_path( target_path: &Path, allowed_write_paths: &[PathBuf], @@ -1031,6 +1327,7 @@ fn find_first_non_existent_component(target_path: &Path) -> Option { #[cfg(test)] mod tests { use super::*; + use codex_protocol::protocol::FileSystemAccessMode; use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxEntry; @@ -1391,6 +1688,189 @@ mod tests { assert!(message.contains(&real_linked_private_str), "{message}"); } + #[test] + fn missing_read_only_subpath_uses_empty_file_bind_data() { + let temp_dir = TempDir::new().expect("temp dir"); + let workspace = temp_dir.path().join("workspace"); + let blocked = workspace.join("blocked"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let workspace_root = + AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); + let blocked_root = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: workspace_root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: blocked_root }, + access: FileSystemAccessMode::Read, + }, + ]); + + let args = + create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + + assert_empty_file_bound_without_perms(&args.args, &blocked); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".git")); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex")); + assert_eq!(args.preserved_files.len(), 1); + assert_eq!( + synthetic_mount_target_paths(&args), + vec![ + blocked.clone(), + workspace.join(".git"), + workspace.join(".agents"), + workspace.join(".codex"), + ] + ); + assert!( + !blocked.exists(), + "missing path mask should not materialize host-side metadata paths at arg construction time", + ); + } + + #[test] + fn transient_empty_preserved_file_uses_empty_file_bind_data() { + let temp_dir = TempDir::new().expect("temp dir"); + let workspace = temp_dir.path().join("workspace"); + let dot_git = workspace.join(".git"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + File::create(&dot_git).expect("create empty .git file"); + + let workspace_root = + AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: workspace_root, + }, + access: FileSystemAccessMode::Write, + }]); + + let args = + create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + let dot_git_str = path_to_string(&dot_git); + + assert_empty_file_bound_without_perms(&args.args, &dot_git); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex")); + assert_eq!( + synthetic_mount_target_paths(&args), + vec![ + dot_git.clone(), + workspace.join(".agents"), + workspace.join(".codex"), + ] + ); + assert!( + !args + .args + .windows(3) + .any(|window| window == ["--ro-bind", dot_git_str.as_str(), dot_git_str.as_str()]), + "transient empty preserved file should not be treated as a stable bind source", + ); + let metadata = std::fs::symlink_metadata(&dot_git).expect("stat .git"); + assert!( + !args.synthetic_mount_targets[0].should_remove_after_bwrap(&metadata), + "pre-existing empty preserved files must not be cleaned up as synthetic targets", + ); + } + + #[test] + fn missing_child_git_under_parent_repo_uses_protected_create_target() { + let temp_dir = TempDir::new().expect("temp dir"); + let repo = temp_dir.path().join("repo"); + let workspace = repo.join("workspace"); + let dot_git = workspace.join(".git"); + std::fs::create_dir_all(repo.join(".git")).expect("create parent .git"); + std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let workspace_root = + AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: workspace_root, + }, + access: FileSystemAccessMode::Write, + }]); + + let args = create_filesystem_args(&policy, &workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex")); + let dot_git_str = path_to_string(&dot_git); + assert!( + !args + .args + .windows(4) + .any(|window| window == ["--perms", "555", "--tmpfs", dot_git_str.as_str()]), + "missing child .git should not shadow parent repo discovery", + ); + assert!( + !synthetic_mount_target_paths(&args).contains(&dot_git), + "missing child .git should not be a transient mount target", + ); + assert_eq!( + protected_create_target_paths(&args), + vec![dot_git], + "missing child .git should fail through protected create cleanup", + ); + } + + #[cfg(unix)] + #[test] + fn symlinked_missing_child_git_under_parent_repo_uses_effective_mount_root() { + let temp_dir = TempDir::new().expect("temp dir"); + let repo = temp_dir.path().join("repo"); + let workspace = repo.join("workspace"); + let link_repo = temp_dir.path().join("link-repo"); + let link_workspace = link_repo.join("workspace"); + let dot_git = workspace.join(".git"); + std::fs::create_dir_all(repo.join(".git")).expect("create parent .git"); + std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::os::unix::fs::symlink(&repo, &link_repo).expect("create symlinked repo"); + + let link_workspace_root = AbsolutePathBuf::from_absolute_path(&link_workspace) + .expect("absolute symlinked workspace"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: link_workspace_root, + }, + access: FileSystemAccessMode::Write, + }]); + + let args = + create_filesystem_args(&policy, &link_workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); + assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex")); + let dot_git_str = path_to_string(&dot_git); + assert!( + !args + .args + .windows(4) + .any(|window| window == ["--perms", "555", "--tmpfs", dot_git_str.as_str()]), + "symlinked missing child .git should not shadow parent repo discovery", + ); + assert!( + !synthetic_mount_target_paths(&args).contains(&dot_git), + "symlinked missing child .git should not be a transient mount target", + ); + assert_eq!( + protected_create_target_paths(&args), + vec![dot_git], + "symlinked missing child .git should fail through protected create cleanup", + ); + } + #[test] fn ignores_missing_writable_roots() { let temp_dir = TempDir::new().expect("temp dir"); @@ -1426,7 +1906,7 @@ mod tests { } #[test] - fn skips_missing_project_root_metadata_carveouts_except_codex() { + fn missing_project_root_metadata_carveouts_use_metadata_path_masks() { let temp_dir = TempDir::new().expect("temp dir"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { @@ -1468,12 +1948,18 @@ mod tests { let dot_agents = path_to_string(&temp_dir.path().join(".agents")); let dot_codex = path_to_string(&temp_dir.path().join(".codex")); - assert!(!args.args.iter().any(|arg| arg == &dot_git)); - assert!(!args.args.iter().any(|arg| arg == &dot_agents)); - assert!( - args.args - .windows(3) - .any(|window| { window == ["--ro-bind", "/dev/null", dot_codex.as_str()] }) + assert_empty_directory_mounted_read_only(&args.args, Path::new(&dot_git)); + assert_empty_directory_mounted_read_only(&args.args, Path::new(&dot_agents)); + assert_empty_directory_mounted_read_only(&args.args, Path::new(&dot_codex)); + assert!(args.preserved_files.is_empty()); + let synthetic_targets = synthetic_mount_target_paths(&args); + assert!(synthetic_targets.contains(&PathBuf::from(&dot_git))); + assert!(synthetic_targets.contains(&PathBuf::from(&dot_agents))); + assert!(synthetic_targets.contains(&PathBuf::from(&dot_codex))); + assert_eq!( + protected_create_target_paths(&args), + Vec::::new(), + "missing protected metadata paths should fail at creation time through read-only mounts", ); } @@ -1513,16 +1999,8 @@ mod tests { let dot_vscode = path_to_string(&temp_dir.path().join(".vscode")); let dot_secrets = path_to_string(&temp_dir.path().join(".secrets")); - assert!( - args.args - .windows(3) - .any(|window| { window == ["--ro-bind", "/dev/null", dot_vscode.as_str()] }) - ); - assert!( - args.args - .windows(3) - .any(|window| { window == ["--ro-bind", "/dev/null", dot_secrets.as_str()] }) - ); + assert_empty_file_bound_without_perms(&args.args, Path::new(&dot_vscode)); + assert_empty_file_bound_without_perms(&args.args, Path::new(&dot_secrets)); } #[test] @@ -1539,6 +2017,18 @@ mod tests { NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH, ) .expect("bwrap fs args"); + assert!(args.preserved_files.is_empty()); + assert_eq!( + synthetic_mount_target_paths(&args), + vec![ + PathBuf::from("/.git"), + PathBuf::from("/.agents"), + PathBuf::from("/.codex"), + PathBuf::from("/dev/.git"), + PathBuf::from("/dev/.agents"), + PathBuf::from("/dev/.codex"), + ] + ); assert_eq!( args.args, vec![ @@ -1553,17 +2043,52 @@ mod tests { "--bind".to_string(), "/".to_string(), "/".to_string(), - // Mask the default protected .codex subpath under that writable - // root. Because the root is `/` in this test, the carveout path - // appears at the filesystem root. - "--ro-bind".to_string(), - "/dev/null".to_string(), + // Mask the default metadata path names under the writable root. + // Because the root is `/` in this test, these carveout paths + // appear directly below `/`. + "--perms".to_string(), + "555".to_string(), + "--tmpfs".to_string(), + "/.git".to_string(), + "--remount-ro".to_string(), + "/.git".to_string(), + "--perms".to_string(), + "555".to_string(), + "--tmpfs".to_string(), + "/.agents".to_string(), + "--remount-ro".to_string(), + "/.agents".to_string(), + "--perms".to_string(), + "555".to_string(), + "--tmpfs".to_string(), + "/.codex".to_string(), + "--remount-ro".to_string(), "/.codex".to_string(), // Rebind /dev after the root bind so device nodes remain // writable/usable inside the writable root. "--bind".to_string(), "/dev".to_string(), "/dev".to_string(), + // Then mask the metadata names that would otherwise be + // creatable below the writable /dev bind. + "--perms".to_string(), + "555".to_string(), + "--tmpfs".to_string(), + "/dev/.git".to_string(), + "--remount-ro".to_string(), + "/dev/.git".to_string(), + "--perms".to_string(), + "555".to_string(), + "--tmpfs".to_string(), + "/dev/.agents".to_string(), + "--remount-ro".to_string(), + "/dev/.agents".to_string(), + "--perms".to_string(), + "555".to_string(), + "--tmpfs".to_string(), + "/dev/.codex".to_string(), + "--remount-ro".to_string(), + "/dev/.codex".to_string(), ] ); } @@ -2027,6 +2552,7 @@ mod tests { let blocked_file_str = path_to_string(blocked_file.as_path()); assert_eq!(args.preserved_files.len(), 1); + assert!(args.synthetic_mount_targets.is_empty()); assert!(args.args.windows(5).any(|window| { window[0] == "--perms" && window[1] == "000" @@ -2130,4 +2656,52 @@ mod tests { "expected file mask for {path}: {args:#?}" ); } + + /// Assert that `path` is backed by an fd-supplied empty file without + /// changing the next mount operation's permissions. + fn assert_empty_file_bound_without_perms(args: &[String], path: &Path) { + let path = path_to_string(path); + assert!( + args.windows(3) + .any(|window| { window[0] == "--ro-bind-data" && window[2] == path }), + "expected empty file bind for {path}: {args:#?}" + ); + assert!( + !args.windows(5).any(|window| { + window[0] == "--perms" + && window[1] == "000" + && window[2] == "--ro-bind-data" + && window[4] == path + }), + "missing path bind should not set explicit file perms for {path}: {args:#?}" + ); + } + + fn assert_empty_directory_mounted_read_only(args: &[String], path: &Path) { + let path = path_to_string(path); + assert!( + args.windows(4) + .any(|window| window == ["--perms", "555", "--tmpfs", path.as_str()]), + "expected empty directory mount for {path}: {args:#?}" + ); + assert!( + args.windows(2) + .any(|window| window == ["--remount-ro", path.as_str()]), + "expected read-only remount for {path}: {args:#?}" + ); + } + + fn synthetic_mount_target_paths(args: &BwrapArgs) -> Vec { + args.synthetic_mount_targets + .iter() + .map(|target| target.path().to_path_buf()) + .collect() + } + + fn protected_create_target_paths(args: &BwrapArgs) -> Vec { + args.protected_create_targets + .iter() + .map(|target| target.path().to_path_buf()) + .collect() + } } diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 21fbbe8261..c88a28b324 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -1,11 +1,21 @@ use clap::Parser; use std::ffi::CString; use std::fmt; +use std::fs; use std::fs::File; +use std::fs::OpenOptions; use std::io::Read; +use std::os::fd::AsRawFd; use std::os::fd::FromRawFd; +use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicI32; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; use crate::bwrap::BwrapNetworkMode; use crate::bwrap::BwrapOptions; @@ -20,6 +30,46 @@ use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::NetworkSandboxPolicy; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; +static BWRAP_CHILD_PID: AtomicI32 = AtomicI32::new(0); +static PENDING_FORWARDED_SIGNAL: AtomicI32 = AtomicI32::new(0); + +const FORWARDED_SIGNALS: &[libc::c_int] = + &[libc::SIGHUP, libc::SIGINT, libc::SIGQUIT, libc::SIGTERM]; +const SYNTHETIC_MOUNT_MARKER_SYNTHETIC: &[u8] = b"synthetic\n"; +const SYNTHETIC_MOUNT_MARKER_EXISTING: &[u8] = b"existing\n"; +const PROTECTED_CREATE_MARKER: &[u8] = b"protected-create\n"; + +#[derive(Debug)] +struct SyntheticMountTargetRegistration { + target: crate::bwrap::SyntheticMountTarget, + marker_file: PathBuf, + marker_dir: PathBuf, +} + +#[derive(Debug)] +struct ProtectedCreateTargetRegistration { + target: crate::bwrap::ProtectedCreateTarget, + marker_file: PathBuf, + marker_dir: PathBuf, +} + +struct ProtectedCreateMonitor { + stop: Arc, + violation: Arc, + handle: thread::JoinHandle<()>, +} + +struct ProtectedCreateWatcher { + fd: libc::c_int, + _watches: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProtectedCreateRemoval { + Directory, + Other, +} + #[derive(Debug, Parser)] /// CLI surface for the Linux sandbox helper. /// @@ -302,7 +352,7 @@ fn run_bwrap_with_proc_fallback( options, ); apply_inner_command_argv0(&mut bwrap_args.args); - exec_bwrap(bwrap_args.args, bwrap_args.preserved_files); + run_or_exec_bwrap(bwrap_args); } fn bwrap_network_mode( @@ -339,6 +389,8 @@ fn build_bwrap_argv( crate::bwrap::BwrapArgs { args: argv, preserved_files: bwrap_args.preserved_files, + synthetic_mount_targets: bwrap_args.synthetic_mount_targets, + protected_create_targets: bwrap_args.protected_create_targets, } } @@ -427,6 +479,802 @@ fn resolve_true_command() -> String { "true".to_string() } +fn run_or_exec_bwrap(bwrap_args: crate::bwrap::BwrapArgs) -> ! { + if bwrap_args.synthetic_mount_targets.is_empty() + && bwrap_args.protected_create_targets.is_empty() + { + exec_bwrap(bwrap_args.args, bwrap_args.preserved_files); + } + run_bwrap_in_child_with_synthetic_mount_cleanup(bwrap_args); +} + +fn run_bwrap_in_child_with_synthetic_mount_cleanup(bwrap_args: crate::bwrap::BwrapArgs) -> ! { + let crate::bwrap::BwrapArgs { + args, + preserved_files, + synthetic_mount_targets, + protected_create_targets, + } = bwrap_args; + let setup_signal_mask = ForwardedSignalMask::block(); + let synthetic_mount_registrations = register_synthetic_mount_targets(&synthetic_mount_targets); + let protected_create_registrations = + register_protected_create_targets(&protected_create_targets); + let exec_start_pipe = create_exec_start_pipe(!protected_create_targets.is_empty()); + let parent_pid = unsafe { libc::getpid() }; + let pid = unsafe { libc::fork() }; + if pid < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to fork for bubblewrap: {err}"); + } + + if pid == 0 { + reset_forwarded_signal_handlers_to_default(); + setup_signal_mask.restore(); + let setpgid_res = unsafe { libc::setpgid(0, 0) }; + if setpgid_res < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to place bubblewrap child in its own process group: {err}"); + } + terminate_with_parent(parent_pid); + wait_for_parent_exec_start(exec_start_pipe[0], exec_start_pipe[1]); + exec_bwrap(args, preserved_files); + } + + close_child_exec_start_read(exec_start_pipe[0]); + let protected_create_monitor = ProtectedCreateMonitor::start(&protected_create_targets); + let signal_forwarders = install_bwrap_signal_forwarders(pid); + release_child_exec_start(exec_start_pipe[1]); + setup_signal_mask.restore(); + let status = wait_for_bwrap_child(pid); + let cleanup_signal_mask = ForwardedSignalMask::block(); + BWRAP_CHILD_PID.store(0, Ordering::SeqCst); + let protected_create_monitor_violation = protected_create_monitor + .map(ProtectedCreateMonitor::stop) + .unwrap_or(false); + cleanup_synthetic_mount_targets(&synthetic_mount_registrations); + let protected_create_violation = protected_create_monitor_violation + || cleanup_protected_create_targets(&protected_create_registrations); + signal_forwarders.restore(); + cleanup_signal_mask.restore(); + exit_with_wait_status_or_policy_violation(status, protected_create_violation); +} + +impl ProtectedCreateMonitor { + fn start(targets: &[crate::bwrap::ProtectedCreateTarget]) -> Option { + if targets.is_empty() { + return None; + } + + let targets = targets.to_vec(); + let stop = Arc::new(AtomicBool::new(false)); + let violation = Arc::new(AtomicBool::new(false)); + let monitor_stop = Arc::clone(&stop); + let monitor_violation = Arc::clone(&violation); + let handle = thread::spawn(move || { + let watcher = ProtectedCreateWatcher::new(&targets); + while !monitor_stop.load(Ordering::SeqCst) { + for target in &targets { + if remove_protected_create_target_best_effort(target).is_some() { + monitor_violation.store(true, Ordering::SeqCst); + } + } + if let Some(watcher) = &watcher { + watcher.wait_for_create_event(&monitor_stop); + } else { + thread::sleep(Duration::from_millis(1)); + } + } + }); + + Some(Self { + stop, + violation, + handle, + }) + } + + fn stop(self) -> bool { + self.stop.store(true, Ordering::SeqCst); + self.handle + .join() + .unwrap_or_else(|_| panic!("protected create monitor thread panicked")); + self.violation.load(Ordering::SeqCst) + } +} + +impl ProtectedCreateWatcher { + fn new(targets: &[crate::bwrap::ProtectedCreateTarget]) -> Option { + let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + if fd < 0 { + return None; + } + + let mut watched_parents = Vec::::new(); + let mut watches = Vec::new(); + for target in targets { + let Some(parent) = target.path().parent() else { + continue; + }; + if watched_parents.iter().any(|watched| watched == parent) { + continue; + } + watched_parents.push(parent.to_path_buf()); + let Ok(parent_cstr) = CString::new(parent.as_os_str().as_bytes()) else { + continue; + }; + let mask = + libc::IN_CREATE | libc::IN_MOVED_TO | libc::IN_DELETE_SELF | libc::IN_MOVE_SELF; + let watch = unsafe { libc::inotify_add_watch(fd, parent_cstr.as_ptr(), mask) }; + if watch >= 0 { + watches.push(watch); + } + } + + if watches.is_empty() { + unsafe { + libc::close(fd); + } + return None; + } + + Some(Self { + fd, + _watches: watches, + }) + } + + fn wait_for_create_event(&self, stop: &AtomicBool) { + let mut poll_fd = libc::pollfd { + fd: self.fd, + events: libc::POLLIN, + revents: 0, + }; + while !stop.load(Ordering::SeqCst) { + let res = unsafe { libc::poll(&mut poll_fd, 1, 10) }; + if res > 0 { + self.drain_events(); + return; + } + if res == 0 { + return; + } + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return; + } + } + + fn drain_events(&self) { + let mut buf = [0_u8; 4096]; + loop { + let read = unsafe { libc::read(self.fd, buf.as_mut_ptr().cast(), buf.len()) }; + if read > 0 { + continue; + } + if read == 0 { + return; + } + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return; + } + } +} + +impl Drop for ProtectedCreateWatcher { + fn drop(&mut self) { + unsafe { + libc::close(self.fd); + } + } +} + +fn create_exec_start_pipe(enabled: bool) -> [libc::c_int; 2] { + if !enabled { + return [-1, -1]; + } + let mut pipe = [-1, -1]; + if unsafe { libc::pipe2(pipe.as_mut_ptr(), libc::O_CLOEXEC) } < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to create bubblewrap exec start pipe: {err}"); + } + pipe +} + +fn wait_for_parent_exec_start(read_fd: libc::c_int, write_fd: libc::c_int) { + if write_fd >= 0 { + unsafe { + libc::close(write_fd); + } + } + if read_fd < 0 { + return; + } + + let mut byte = [0_u8; 1]; + loop { + let read = unsafe { libc::read(read_fd, byte.as_mut_ptr().cast(), byte.len()) }; + if read >= 0 { + break; + } + let err = std::io::Error::last_os_error(); + if err.kind() != std::io::ErrorKind::Interrupted { + break; + } + } + unsafe { + libc::close(read_fd); + } +} + +fn close_child_exec_start_read(read_fd: libc::c_int) { + if read_fd >= 0 { + unsafe { + libc::close(read_fd); + } + } +} + +fn release_child_exec_start(write_fd: libc::c_int) { + if write_fd < 0 { + return; + } + let byte = [0_u8; 1]; + unsafe { + libc::write(write_fd, byte.as_ptr().cast(), byte.len()); + libc::close(write_fd); + } +} + +struct ForwardedSignalMask { + previous: libc::sigset_t, +} + +struct ForwardedSignalHandlers { + previous: Vec<(libc::c_int, libc::sigaction)>, +} + +impl ForwardedSignalMask { + fn block() -> Self { + let mut blocked: libc::sigset_t = unsafe { std::mem::zeroed() }; + let mut previous: libc::sigset_t = unsafe { std::mem::zeroed() }; + unsafe { + libc::sigemptyset(&mut blocked); + for signal in FORWARDED_SIGNALS { + libc::sigaddset(&mut blocked, *signal); + } + if libc::sigprocmask(libc::SIG_BLOCK, &blocked, &mut previous) < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to block bubblewrap forwarded signals: {err}"); + } + } + Self { previous } + } + + fn restore(&self) { + let mut restored = self.previous; + unsafe { + for signal in FORWARDED_SIGNALS { + libc::sigdelset(&mut restored, *signal); + } + if libc::sigprocmask(libc::SIG_SETMASK, &restored, std::ptr::null_mut()) < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to restore bubblewrap forwarded signals: {err}"); + } + } + } +} + +fn terminate_with_parent(parent_pid: libc::pid_t) { + let res = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) }; + if res < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to set bubblewrap child parent-death signal: {err}"); + } + if unsafe { libc::getppid() } != parent_pid { + unsafe { + libc::raise(libc::SIGTERM); + } + } +} + +impl ForwardedSignalHandlers { + fn restore(self) { + BWRAP_CHILD_PID.store(0, Ordering::SeqCst); + PENDING_FORWARDED_SIGNAL.store(0, Ordering::SeqCst); + for (signal, previous_action) in self.previous { + unsafe { + if libc::sigaction(signal, &previous_action, std::ptr::null_mut()) < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to restore bubblewrap signal handler for {signal}: {err}"); + } + } + } + } +} + +fn install_bwrap_signal_forwarders(pid: libc::pid_t) -> ForwardedSignalHandlers { + BWRAP_CHILD_PID.store(pid, Ordering::SeqCst); + let mut previous = Vec::with_capacity(FORWARDED_SIGNALS.len()); + for signal in FORWARDED_SIGNALS { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + let mut previous_action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = forward_signal_to_bwrap_child as *const () as libc::sighandler_t; + unsafe { + libc::sigemptyset(&mut action.sa_mask); + if libc::sigaction(*signal, &action, &mut previous_action) < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to install bubblewrap signal forwarder for {signal}: {err}"); + } + } + previous.push((*signal, previous_action)); + } + replay_pending_forwarded_signal(pid); + ForwardedSignalHandlers { previous } +} + +extern "C" fn forward_signal_to_bwrap_child(signal: libc::c_int) { + PENDING_FORWARDED_SIGNAL.store(signal, Ordering::SeqCst); + let pid = BWRAP_CHILD_PID.load(Ordering::SeqCst); + if pid > 0 { + send_signal_to_bwrap_child(pid, signal); + } +} + +fn replay_pending_forwarded_signal(pid: libc::pid_t) { + let signal = PENDING_FORWARDED_SIGNAL.swap(0, Ordering::SeqCst); + if signal > 0 { + send_signal_to_bwrap_child(pid, signal); + } +} + +fn send_signal_to_bwrap_child(pid: libc::pid_t, signal: libc::c_int) { + unsafe { + libc::kill(-pid, signal); + libc::kill(pid, signal); + } +} + +fn reset_forwarded_signal_handlers_to_default() { + for signal in FORWARDED_SIGNALS { + unsafe { + if libc::signal(*signal, libc::SIG_DFL) == libc::SIG_ERR { + let err = std::io::Error::last_os_error(); + panic!("failed to reset bubblewrap signal handler for {signal}: {err}"); + } + } + } +} + +fn wait_for_bwrap_child(pid: libc::pid_t) -> libc::c_int { + loop { + let mut status: libc::c_int = 0; + let wait_res = unsafe { libc::waitpid(pid, &mut status as *mut libc::c_int, 0) }; + if wait_res >= 0 { + return status; + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + panic!("waitpid failed for bubblewrap child: {err}"); + } +} + +fn register_synthetic_mount_targets( + targets: &[crate::bwrap::SyntheticMountTarget], +) -> Vec { + with_synthetic_mount_registry_lock(|| { + targets + .iter() + .map(|target| { + let marker_dir = synthetic_mount_marker_dir(target.path()); + fs::create_dir_all(&marker_dir).unwrap_or_else(|err| { + panic!( + "failed to create synthetic bubblewrap mount marker directory {}: {err}", + marker_dir.display() + ) + }); + let target = if target.preserves_pre_existing_path() + && synthetic_mount_marker_dir_has_active_synthetic_owner(&marker_dir) + { + match target.kind() { + crate::bwrap::SyntheticMountTargetKind::EmptyFile => { + crate::bwrap::SyntheticMountTarget::missing(target.path()) + } + crate::bwrap::SyntheticMountTargetKind::EmptyDirectory => { + crate::bwrap::SyntheticMountTarget::missing_empty_directory( + target.path(), + ) + } + } + } else { + target.clone() + }; + let marker_file = marker_dir.join(std::process::id().to_string()); + fs::write(&marker_file, synthetic_mount_marker_contents(&target)).unwrap_or_else( + |err| { + panic!( + "failed to register synthetic bubblewrap mount target {}: {err}", + target.path().display() + ) + }, + ); + SyntheticMountTargetRegistration { + target, + marker_file, + marker_dir, + } + }) + .collect() + }) +} + +fn register_protected_create_targets( + targets: &[crate::bwrap::ProtectedCreateTarget], +) -> Vec { + with_synthetic_mount_registry_lock(|| { + targets + .iter() + .map(|target| { + let marker_dir = synthetic_mount_marker_dir(target.path()); + fs::create_dir_all(&marker_dir).unwrap_or_else(|err| { + panic!( + "failed to create protected create marker directory {}: {err}", + marker_dir.display() + ) + }); + let marker_file = marker_dir.join(std::process::id().to_string()); + fs::write(&marker_file, PROTECTED_CREATE_MARKER).unwrap_or_else(|err| { + panic!( + "failed to register protected create target {}: {err}", + target.path().display() + ) + }); + ProtectedCreateTargetRegistration { + target: target.clone(), + marker_file, + marker_dir, + } + }) + .collect() + }) +} + +fn synthetic_mount_marker_contents(target: &crate::bwrap::SyntheticMountTarget) -> &'static [u8] { + if target.preserves_pre_existing_path() { + SYNTHETIC_MOUNT_MARKER_EXISTING + } else { + SYNTHETIC_MOUNT_MARKER_SYNTHETIC + } +} + +fn synthetic_mount_marker_dir_has_active_synthetic_owner(marker_dir: &Path) -> bool { + synthetic_mount_marker_dir_has_active_process_matching(marker_dir, |path| { + match fs::read(path) { + Ok(contents) => contents == SYNTHETIC_MOUNT_MARKER_SYNTHETIC, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, + Err(err) => panic!( + "failed to read synthetic bubblewrap mount marker {}: {err}", + path.display() + ), + } + }) +} + +fn synthetic_mount_marker_dir_has_active_process(marker_dir: &Path) -> bool { + synthetic_mount_marker_dir_has_active_process_matching(marker_dir, |_| true) +} + +fn synthetic_mount_marker_dir_has_active_process_matching( + marker_dir: &Path, + matches_marker: impl Fn(&Path) -> bool, +) -> bool { + let entries = match fs::read_dir(marker_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return false, + Err(err) => panic!( + "failed to read synthetic bubblewrap mount marker directory {}: {err}", + marker_dir.display() + ), + }; + for entry in entries { + let entry = entry.unwrap_or_else(|err| { + panic!( + "failed to read synthetic bubblewrap mount marker in {}: {err}", + marker_dir.display() + ) + }); + let path = entry.path(); + let Some(pid) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.parse::().ok()) + else { + continue; + }; + if !process_is_active(pid) { + match fs::remove_file(&path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => panic!( + "failed to remove stale synthetic bubblewrap mount marker {}: {err}", + path.display() + ), + } + continue; + } + let matches_marker = matches_marker(&path); + if matches_marker { + return true; + } + } + false +} + +fn cleanup_synthetic_mount_targets(targets: &[SyntheticMountTargetRegistration]) { + with_synthetic_mount_registry_lock(|| { + for target in targets.iter().rev() { + match fs::remove_file(&target.marker_file) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => panic!( + "failed to unregister synthetic bubblewrap mount target {}: {err}", + target.target.path().display() + ), + } + } + + for target in targets.iter().rev() { + if synthetic_mount_marker_dir_has_active_process(&target.marker_dir) { + continue; + } + remove_synthetic_mount_target(&target.target); + match fs::remove_dir(&target.marker_dir) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {} + Err(err) => panic!( + "failed to remove synthetic bubblewrap mount marker directory {}: {err}", + target.marker_dir.display() + ), + } + } + }); +} + +fn cleanup_protected_create_targets(targets: &[ProtectedCreateTargetRegistration]) -> bool { + with_synthetic_mount_registry_lock(|| { + for target in targets.iter().rev() { + match fs::remove_file(&target.marker_file) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => panic!( + "failed to unregister protected create target {}: {err}", + target.target.path().display() + ), + } + } + + let mut violation = false; + for target in targets.iter().rev() { + if synthetic_mount_marker_dir_has_active_process(&target.marker_dir) { + if target.target.path().exists() { + violation = true; + } + continue; + } + violation |= remove_protected_create_target(&target.target); + match fs::remove_dir(&target.marker_dir) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {} + Err(err) => panic!( + "failed to remove protected create marker directory {}: {err}", + target.marker_dir.display() + ), + } + } + violation + }) +} + +fn remove_protected_create_target(target: &crate::bwrap::ProtectedCreateTarget) -> bool { + for attempt in 0..100 { + match try_remove_protected_create_target(target) { + Ok(removal) => return removal.is_some(), + Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty && attempt < 99 => { + thread::sleep(Duration::from_millis(1)); + } + Err(err) => { + panic!( + "failed to remove protected create target {}: {err}", + target.path().display() + ); + } + } + } + unreachable!("protected create removal retry loop should return or panic") +} + +fn remove_protected_create_target_best_effort( + target: &crate::bwrap::ProtectedCreateTarget, +) -> Option { + for _ in 0..100 { + match try_remove_protected_create_target(target) { + Ok(removal) => return removal, + Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => { + thread::sleep(Duration::from_millis(1)); + } + Err(_) => return Some(ProtectedCreateRemoval::Other), + } + } + Some(ProtectedCreateRemoval::Other) +} + +fn try_remove_protected_create_target( + target: &crate::bwrap::ProtectedCreateTarget, +) -> std::io::Result> { + let path = target.path(); + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + + let removal = if metadata.is_dir() { + ProtectedCreateRemoval::Directory + } else { + ProtectedCreateRemoval::Other + }; + let result = if removal == ProtectedCreateRemoval::Directory { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + match result { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + } + eprintln!( + "sandbox blocked creation of protected workspace metadata path {}", + path.display() + ); + Ok(Some(removal)) +} + +fn remove_synthetic_mount_target(target: &crate::bwrap::SyntheticMountTarget) { + let path = target.path(); + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return, + Err(err) => panic!( + "failed to inspect synthetic bubblewrap mount target {}: {err}", + path.display() + ), + }; + if !target.should_remove_after_bwrap(&metadata) { + return; + } + match target.kind() { + crate::bwrap::SyntheticMountTargetKind::EmptyFile => match fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => panic!( + "failed to remove synthetic bubblewrap mount target {}: {err}", + path.display() + ), + }, + crate::bwrap::SyntheticMountTargetKind::EmptyDirectory => match fs::remove_dir(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {} + Err(err) => panic!( + "failed to remove synthetic bubblewrap mount target {}: {err}", + path.display() + ), + }, + } +} + +fn process_is_active(pid: libc::pid_t) -> bool { + let result = unsafe { libc::kill(pid, 0) }; + if result == 0 { + return true; + } + let err = std::io::Error::last_os_error(); + !matches!(err.raw_os_error(), Some(libc::ESRCH)) +} + +fn with_synthetic_mount_registry_lock(f: impl FnOnce() -> T) -> T { + let registry_root = synthetic_mount_registry_root(); + fs::create_dir_all(®istry_root).unwrap_or_else(|err| { + panic!( + "failed to create synthetic bubblewrap mount registry {}: {err}", + registry_root.display() + ) + }); + let lock_path = registry_root.join("lock"); + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .unwrap_or_else(|err| { + panic!( + "failed to open synthetic bubblewrap mount registry lock {}: {err}", + lock_path.display() + ) + }); + if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } < 0 { + let err = std::io::Error::last_os_error(); + panic!( + "failed to lock synthetic bubblewrap mount registry {}: {err}", + lock_path.display() + ); + } + let result = f(); + if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_UN) } < 0 { + let err = std::io::Error::last_os_error(); + panic!( + "failed to unlock synthetic bubblewrap mount registry {}: {err}", + lock_path.display() + ); + } + result +} + +fn synthetic_mount_marker_dir(path: &Path) -> PathBuf { + synthetic_mount_registry_root().join(format!("{:016x}", hash_path(path))) +} + +fn synthetic_mount_registry_root() -> PathBuf { + std::env::temp_dir().join("codex-bwrap-synthetic-mount-targets") +} + +fn hash_path(path: &Path) -> u64 { + let mut hash = 0xcbf29ce484222325u64; + for byte in path.as_os_str().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +fn exit_with_wait_status(status: libc::c_int) -> ! { + if libc::WIFEXITED(status) { + std::process::exit(libc::WEXITSTATUS(status)); + } + + if libc::WIFSIGNALED(status) { + let signal = libc::WTERMSIG(status); + unsafe { + libc::signal(signal, libc::SIG_DFL); + libc::kill(libc::getpid(), signal); + } + std::process::exit(128 + signal); + } + + std::process::exit(1); +} + +fn exit_with_wait_status_or_policy_violation( + status: libc::c_int, + protected_create_violation: bool, +) -> ! { + if protected_create_violation && libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 { + std::process::exit(1); + } + + exit_with_wait_status(status); +} + /// Run a short-lived bubblewrap preflight in a child process and capture stderr. /// /// Strategy: @@ -440,6 +1288,16 @@ fn resolve_true_command() -> String { /// command, and reads are bounded to a fixed max size. fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> String { const MAX_PREFLIGHT_STDERR_BYTES: u64 = 64 * 1024; + let crate::bwrap::BwrapArgs { + args, + preserved_files, + synthetic_mount_targets, + protected_create_targets, + } = bwrap_args; + let setup_signal_mask = ForwardedSignalMask::block(); + let synthetic_mount_registrations = register_synthetic_mount_targets(&synthetic_mount_targets); + let protected_create_registrations = + register_protected_create_targets(&protected_create_targets); let mut pipe_fds = [0; 2]; let pipe_res = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) }; @@ -457,6 +1315,8 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str } if pid == 0 { + reset_forwarded_signal_handlers_to_default(); + setup_signal_mask.restore(); // Child: redirect stderr to the pipe, then run bubblewrap. unsafe { close_fd_or_panic(read_fd, "close read end in bubblewrap child"); @@ -467,9 +1327,11 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str close_fd_or_panic(write_fd, "close write end in bubblewrap child"); } - exec_bwrap(bwrap_args.args, bwrap_args.preserved_files); + exec_bwrap(args, preserved_files); } + let signal_forwarders = install_bwrap_signal_forwarders(pid); + setup_signal_mask.restore(); // Parent: close the write end and read stderr while the child runs. close_fd_or_panic(write_fd, "close write end in bubblewrap parent"); @@ -481,11 +1343,15 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str panic!("failed to read bubblewrap stderr: {err}"); } - let mut status: libc::c_int = 0; - let wait_res = unsafe { libc::waitpid(pid, &mut status as *mut libc::c_int, 0) }; - if wait_res < 0 { - let err = std::io::Error::last_os_error(); - panic!("waitpid failed for bubblewrap child: {err}"); + let status = wait_for_bwrap_child(pid); + let cleanup_signal_mask = ForwardedSignalMask::block(); + BWRAP_CHILD_PID.store(0, Ordering::SeqCst); + cleanup_synthetic_mount_targets(&synthetic_mount_registrations); + cleanup_protected_create_targets(&protected_create_registrations); + signal_forwarders.restore(); + cleanup_signal_mask.restore(); + if libc::WIFSIGNALED(status) { + exit_with_wait_status(status); } String::from_utf8_lossy(&stderr_bytes).into_owned() diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 2b3f963557..228cea6e5d 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -1,6 +1,10 @@ #[cfg(test)] use super::*; #[cfg(test)] +use crate::linux_run_main::install_bwrap_signal_forwarders; +#[cfg(test)] +use crate::linux_run_main::wait_for_bwrap_child; +#[cfg(test)] use codex_protocol::models::PermissionProfile; #[cfg(test)] use codex_protocol::protocol::FileSystemSandboxPolicy; @@ -265,6 +269,180 @@ fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() { assert!(argv.iter().any(|arg| arg == "--")); } +#[test] +fn cleanup_synthetic_mount_targets_removes_only_empty_mount_targets() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let empty_file = temp_dir.path().join(".git"); + let empty_dir = temp_dir.path().join(".agents"); + let non_empty_file = temp_dir.path().join("non-empty"); + let missing_file = temp_dir.path().join(".missing"); + std::fs::write(&empty_file, "").expect("write empty file"); + std::fs::create_dir(&empty_dir).expect("create empty dir"); + std::fs::write(&non_empty_file, "keep").expect("write nonempty file"); + + let registrations = register_synthetic_mount_targets(&[ + crate::bwrap::SyntheticMountTarget::missing(&empty_file), + crate::bwrap::SyntheticMountTarget::missing_empty_directory(&empty_dir), + crate::bwrap::SyntheticMountTarget::missing(&non_empty_file), + crate::bwrap::SyntheticMountTarget::missing(&missing_file), + ]); + cleanup_synthetic_mount_targets(®istrations); + + assert!(!empty_file.exists()); + assert!(!empty_dir.exists()); + assert_eq!( + std::fs::read_to_string(&non_empty_file).expect("read nonempty file"), + "keep" + ); + assert!(!missing_file.exists()); +} + +#[test] +fn cleanup_synthetic_mount_targets_waits_for_other_active_registrations() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let empty_file = temp_dir.path().join(".git"); + std::fs::write(&empty_file, "").expect("write empty file"); + let target = crate::bwrap::SyntheticMountTarget::missing(&empty_file); + + let registrations = register_synthetic_mount_targets(std::slice::from_ref(&target)); + let active_marker = registrations[0].marker_dir.join("1"); + std::fs::write(&active_marker, "").expect("write active marker"); + + cleanup_synthetic_mount_targets(®istrations); + assert!(empty_file.exists()); + + std::fs::remove_file(active_marker).expect("remove active marker"); + let registrations = register_synthetic_mount_targets(std::slice::from_ref(&target)); + cleanup_synthetic_mount_targets(®istrations); + + assert!(!empty_file.exists()); +} + +#[test] +fn cleanup_synthetic_mount_targets_removes_transient_file_after_concurrent_owner_exits() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let empty_file = temp_dir.path().join(".git"); + let first_target = crate::bwrap::SyntheticMountTarget::missing(&empty_file); + + let first_registrations = register_synthetic_mount_targets(&[first_target]); + std::fs::write(&empty_file, "").expect("write transient empty file"); + let active_marker = first_registrations[0].marker_dir.join("1"); + std::fs::write(&active_marker, SYNTHETIC_MOUNT_MARKER_SYNTHETIC).expect("write active marker"); + let metadata = std::fs::symlink_metadata(&empty_file).expect("stat empty file"); + let second_target = + crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata); + let second_registrations = register_synthetic_mount_targets(&[second_target]); + + cleanup_synthetic_mount_targets(&first_registrations); + assert!(empty_file.exists()); + + std::fs::remove_file(active_marker).expect("remove active marker"); + cleanup_synthetic_mount_targets(&second_registrations); + + assert!(!empty_file.exists()); +} + +#[test] +fn cleanup_synthetic_mount_targets_preserves_real_pre_existing_empty_file() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let empty_file = temp_dir.path().join(".git"); + std::fs::write(&empty_file, "").expect("write pre-existing empty file"); + let metadata = std::fs::symlink_metadata(&empty_file).expect("stat empty file"); + let first_target = + crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata); + let second_target = + crate::bwrap::SyntheticMountTarget::existing_empty_file(&empty_file, &metadata); + + let first_registrations = register_synthetic_mount_targets(&[first_target]); + let second_registrations = register_synthetic_mount_targets(&[second_target]); + + cleanup_synthetic_mount_targets(&first_registrations); + cleanup_synthetic_mount_targets(&second_registrations); + + assert!(empty_file.exists()); +} + +#[test] +fn cleanup_protected_create_targets_removes_created_path_and_reports_violation() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let dot_git = temp_dir.path().join(".git"); + let target = crate::bwrap::ProtectedCreateTarget::missing(&dot_git); + + let registrations = register_protected_create_targets(&[target]); + std::fs::create_dir(&dot_git).expect("create protected path"); + let violation = cleanup_protected_create_targets(®istrations); + + assert!(violation); + assert!(!dot_git.exists()); +} + +#[test] +fn cleanup_protected_create_targets_waits_for_other_active_registrations() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let dot_git = temp_dir.path().join(".git"); + let target = crate::bwrap::ProtectedCreateTarget::missing(&dot_git); + + let registrations = register_protected_create_targets(std::slice::from_ref(&target)); + let active_marker = registrations[0].marker_dir.join("1"); + std::fs::write(&active_marker, PROTECTED_CREATE_MARKER).expect("write active marker"); + std::fs::write(&dot_git, "").expect("create protected path"); + + let violation = cleanup_protected_create_targets(®istrations); + assert!(violation); + assert!(dot_git.exists()); + + std::fs::remove_file(active_marker).expect("remove active marker"); + let registrations = register_protected_create_targets(std::slice::from_ref(&target)); + let violation = cleanup_protected_create_targets(®istrations); + + assert!(violation); + assert!(!dot_git.exists()); +} + +#[test] +fn bwrap_signal_forwarder_terminates_child_and_keeps_parent_alive() { + let supervisor_pid = unsafe { libc::fork() }; + assert!(supervisor_pid >= 0, "failed to fork supervisor"); + + if supervisor_pid == 0 { + run_bwrap_signal_forwarder_test_supervisor(); + } + + let status = wait_for_bwrap_child(supervisor_pid); + assert!(libc::WIFEXITED(status), "supervisor status: {status}"); + assert_eq!(libc::WEXITSTATUS(status), 0); +} + +#[cfg(test)] +fn run_bwrap_signal_forwarder_test_supervisor() -> ! { + let child_pid = unsafe { libc::fork() }; + if child_pid < 0 { + unsafe { + libc::_exit(2); + } + } + + if child_pid == 0 { + loop { + unsafe { + libc::pause(); + } + } + } + + install_bwrap_signal_forwarders(child_pid); + unsafe { + libc::raise(libc::SIGTERM); + } + + let status = wait_for_bwrap_child(child_pid); + let child_terminated_by_sigterm = + libc::WIFSIGNALED(status) && libc::WTERMSIG(status) == libc::SIGTERM; + unsafe { + libc::_exit(if child_terminated_by_sigterm { 0 } else { 1 }); + } +} + #[test] fn managed_proxy_inner_command_includes_route_spec() { let permission_profile = read_only_permission_profile(); diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 3b97e933bb..efbcd0b486 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -26,17 +26,17 @@ use tempfile::NamedTempFile; // At least on GitHub CI, the arm64 tests appear to need longer timeouts. #[cfg(not(target_arch = "aarch64"))] -const SHORT_TIMEOUT_MS: u64 = 200; +const SHORT_TIMEOUT_MS: u64 = 5_000; #[cfg(target_arch = "aarch64")] const SHORT_TIMEOUT_MS: u64 = 5_000; #[cfg(not(target_arch = "aarch64"))] -const LONG_TIMEOUT_MS: u64 = 1_000; +const LONG_TIMEOUT_MS: u64 = 5_000; #[cfg(target_arch = "aarch64")] const LONG_TIMEOUT_MS: u64 = 5_000; #[cfg(not(target_arch = "aarch64"))] -const NETWORK_TIMEOUT_MS: u64 = 2_000; +const NETWORK_TIMEOUT_MS: u64 = 10_000; #[cfg(target_arch = "aarch64")] const NETWORK_TIMEOUT_MS: u64 = 10_000; @@ -47,6 +47,14 @@ fn create_env_from_core_vars() -> HashMap { create_env(&policy, /*thread_id*/ None) } +fn codex_linux_sandbox_exe() -> PathBuf { + let sandbox_program = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox")); + match sandbox_program.canonicalize() { + Ok(path) => path, + Err(_) => sandbox_program, + } +} + #[expect(clippy::print_stdout)] async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { let output = run_cmd_output(cmd, writable_roots, timeout_ms).await; @@ -111,6 +119,57 @@ async fn run_cmd_result_with_permission_profile( use_legacy_landlock: bool, ) -> Result { let cwd = AbsolutePathBuf::current_dir().expect("cwd should exist"); + run_cmd_result_with_permission_profile_for_cwd( + cmd, + cwd, + permission_profile, + timeout_ms, + use_legacy_landlock, + ) + .await +} + +#[expect(clippy::expect_used)] +async fn run_cmd_result_with_cwd_and_writable_roots( + cmd: &[&str], + cwd: &std::path::Path, + writable_roots: &[PathBuf], + timeout_ms: u64, + use_legacy_landlock: bool, + network_access: bool, +) -> Result { + let writable_roots = writable_roots + .iter() + .map(|path| AbsolutePathBuf::try_from(path.as_path()).unwrap()) + .collect::>(); + let permission_profile = PermissionProfile::workspace_write_with( + &writable_roots, + if network_access { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + }, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let cwd = AbsolutePathBuf::try_from(cwd).expect("cwd should be absolute"); + run_cmd_result_with_permission_profile_for_cwd( + cmd, + cwd, + permission_profile, + timeout_ms, + use_legacy_landlock, + ) + .await +} + +async fn run_cmd_result_with_permission_profile_for_cwd( + cmd: &[&str], + cwd: AbsolutePathBuf, + permission_profile: PermissionProfile, + timeout_ms: u64, + use_legacy_landlock: bool, +) -> Result { let sandbox_cwd = cwd.clone(); let params = ExecParams { command: cmd.iter().copied().map(str::to_owned).collect(), @@ -125,8 +184,7 @@ async fn run_cmd_result_with_permission_profile( justification: None, arg0: None, }; - let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); - let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program)); + let codex_linux_sandbox_exe = Some(codex_linux_sandbox_exe()); process_exec_tool_call( params, @@ -384,8 +442,7 @@ async fn assert_network_blocked(cmd: &[&str]) { arg0: None, }; - let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); - let codex_linux_sandbox_exe: Option = Some(PathBuf::from(sandbox_program)); + let codex_linux_sandbox_exe: Option = Some(codex_linux_sandbox_exe()); let permission_profile = PermissionProfile::read_only(); let result = process_exec_tool_call( params, @@ -530,6 +587,129 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() { assert_ne!(codex_output.exit_code, 0); } +#[tokio::test] +async fn sandbox_keeps_parent_repo_discovery_while_blocking_child_metadata() { + if should_skip_bwrap_tests().await { + eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable"); + return; + } + + let git_available = std::process::Command::new("git") + .arg("--version") + .status() + .is_ok_and(|status| status.success()); + let python_available = std::process::Command::new("python3") + .arg("--version") + .status() + .is_ok_and(|status| status.success()); + if !git_available || !python_available { + eprintln!("skipping bwrap test: git or python3 is unavailable"); + return; + } + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let repo = tmpdir.path().join("repo"); + let subdir = repo.join("sub"); + std::fs::create_dir_all(&subdir).expect("create nested workspace"); + assert!( + std::process::Command::new("git") + .arg("init") + .arg("-q") + .arg(&repo) + .status() + .expect("git init should run") + .success(), + "git init should create parent repo" + ); + + let repo = repo.to_string_lossy(); + let script = format!( + r#"set -e +test "$(git rev-parse --show-toplevel)" = '{repo}' +git status --short > status.before +if grep -E '(^|[[:space:]])\.(git|codex|agents)(/|$)' status.before; then + cat status.before + exit 21 +fi +"#, + ); + + let output = run_cmd_result_with_cwd_and_writable_roots( + &["bash", "-lc", &script], + &subdir, + std::slice::from_ref(&subdir), + LONG_TIMEOUT_MS, + /*use_legacy_landlock*/ false, + /*network_access*/ true, + ) + .await + .expect("sandboxed command should execute"); + + assert_eq!( + output.exit_code, 0, + "stdout:\n{}\nstderr:\n{}", + output.stdout.text, output.stderr.text + ); + + let git_init_output = expect_denied( + run_cmd_result_with_cwd_and_writable_roots( + &["git", "init", "-q"], + &subdir, + std::slice::from_ref(&subdir), + LONG_TIMEOUT_MS, + /*use_legacy_landlock*/ false, + /*network_access*/ true, + ) + .await, + "child git init should be denied", + ); + assert_ne!(git_init_output.exit_code, 0); + assert!(!subdir.join(".git").exists()); + + let mkdir_codex_output = expect_denied( + run_cmd_result_with_cwd_and_writable_roots( + &["mkdir", ".codex"], + &subdir, + std::slice::from_ref(&subdir), + LONG_TIMEOUT_MS, + /*use_legacy_landlock*/ false, + /*network_access*/ true, + ) + .await, + "child .codex directory creation should be denied", + ); + assert_ne!(mkdir_codex_output.exit_code, 0); + assert!(!subdir.join(".codex").exists()); + + let script = format!( + r#"set -e +test "$(git rev-parse --show-toplevel)" = '{repo}' +printf '%s\n' 'import json, sys' 'for line in sys.stdin:' ' obj = json.loads(line)' ' print(obj.get("message", obj))' > jsonl_viewer.py +printf '%s\n' '{{"message":"ok"}}' | python3 jsonl_viewer.py | grep -q ok +"#, + ); + let output = run_cmd_result_with_cwd_and_writable_roots( + &["bash", "-lc", &script], + &subdir, + std::slice::from_ref(&subdir), + LONG_TIMEOUT_MS, + /*use_legacy_landlock*/ false, + /*network_access*/ true, + ) + .await + .expect("sandboxed command should execute"); + assert_eq!( + output.exit_code, 0, + "stdout:\n{}\nstderr:\n{}", + output.stdout.text, output.stderr.text + ); + + assert!(subdir.join("jsonl_viewer.py").is_file()); + assert!(!subdir.join(".git").exists()); + assert!(!subdir.join(".codex").exists()); + assert!(!subdir.join(".agents").exists()); +} + #[tokio::test] async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() { if should_skip_bwrap_tests().await { @@ -543,7 +723,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() { let blocked_target = blocked.join("secret.txt"); // These tests bypass the usual legacy-policy bridge, so explicitly keep // the sandbox helper binary and minimal runtime paths readable. - let sandbox_helper_dir = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox")) + let sandbox_helper_dir = codex_linux_sandbox_exe() .parent() .expect("sandbox helper should have a parent") .to_path_buf(); @@ -611,7 +791,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() { let allowed_target = allowed.join("note.txt"); // These tests bypass the usual legacy-policy bridge, so explicitly keep // the sandbox helper binary and minimal runtime paths readable. - let sandbox_helper_dir = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox")) + let sandbox_helper_dir = codex_linux_sandbox_exe() .parent() .expect("sandbox helper should have a parent") .to_path_buf(); diff --git a/codex-rs/login/BUILD.bazel b/codex-rs/login/BUILD.bazel index fe5866577f..1265a83779 100644 --- a/codex-rs/login/BUILD.bazel +++ b/codex-rs/login/BUILD.bazel @@ -6,5 +6,6 @@ codex_rust_crate( compile_data = [ "src/assets/error.html", "src/assets/success.html", + "src/assets/success_legacy.html", ], ) diff --git a/codex-rs/login/src/assets/success.html b/codex-rs/login/src/assets/success.html index 382f864c6a..5b01ceb90e 100644 --- a/codex-rs/login/src/assets/success.html +++ b/codex-rs/login/src/assets/success.html @@ -2,197 +2,236 @@ - Sign into Codex - + + Signed in to Codex + -
-
-
- -
Signed in to Codex
-
- - + - - \ No newline at end of file + diff --git a/codex-rs/login/src/assets/success_legacy.html b/codex-rs/login/src/assets/success_legacy.html new file mode 100644 index 0000000000..015866ee54 --- /dev/null +++ b/codex-rs/login/src/assets/success_legacy.html @@ -0,0 +1,197 @@ + + + + + Sign into Codex + + + + +
+
+
+ +
Signed in to Codex
+
+ + +
+
+ + + diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 0c7b810184..8e398ce890 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -50,6 +50,8 @@ use tracing::warn; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; +// Keep in sync with the Codex CLI Hydra redirect URI allow-list. +const FALLBACK_PORT: u16 = 1457; static LOGIN_ERROR_PAGE_TEMPLATE: LazyLock