Merge branch 'etraut/tui-cleanup-5' into etraut/tui-cleanup-6

This commit is contained in:
Eric Traut
2026-04-29 21:04:40 -07:00
205 changed files with 11272 additions and 4193 deletions
+4 -1
View File
@@ -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
+6
View File
@@ -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<Output = T> + 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.
+36 -14
View File
@@ -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]]
+5
View File
@@ -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",
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "agent-graph-store",
crate_name = "codex_agent_graph_store",
)
+25
View File
@@ -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"] }
+20
View File
@@ -0,0 +1,20 @@
/// Result type returned by agent graph store operations.
pub type AgentGraphStoreResult<T> = Result<T, AgentGraphStoreError>;
/// 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,
},
}
+12
View File
@@ -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;
+325
View File
@@ -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<StateRuntime>,
}
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<StateRuntime>) -> 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<ThreadSpawnEdgeStatus>,
) -> AgentGraphStoreResult<Vec<ThreadId>> {
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<ThreadSpawnEdgeStatus>,
) -> AgentGraphStoreResult<Vec<ThreadId>> {
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<StateRuntime>,
_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::<ThreadId>::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]
);
}
}
+55
View File
@@ -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<ThreadSpawnEdgeStatus>,
) -> AgentGraphStoreResult<Vec<ThreadId>>;
/// 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<ThreadSpawnEdgeStatus>,
) -> AgentGraphStoreResult<Vec<ThreadId>>;
}
+42
View File
@@ -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::<ThreadSpawnEdgeStatus>("\"open\"")
.expect("open status should deserialize"),
ThreadSpawnEdgeStatus::Open
);
assert_eq!(
serde_json::from_str::<ThreadSpawnEdgeStatus>("\"closed\"")
.expect("closed status should deserialize"),
ThreadSpawnEdgeStatus::Closed
);
}
}
+131 -67
View File
@@ -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,
)
+35 -4
View File
@@ -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;
+221
View File
@@ -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<AnalyticsFact>) {
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)));
}
+3 -2
View File
@@ -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<ClientResponse>,
request_id: RequestId,
response: Box<ClientResponsePayload>,
},
ErrorResponse {
connection_id: u64,
+4 -1
View File
@@ -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,
@@ -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": {
@@ -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.<id>]` 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": [
@@ -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.<id>]` 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": [
@@ -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"
}
@@ -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"
}
@@ -23,6 +23,9 @@
},
{
"properties": {
"codexStreamlinedLogin": {
"type": "boolean"
},
"type": {
"enum": [
"chatgpt"
@@ -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",
@@ -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.<id>]` 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": [
@@ -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",
@@ -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.<id>]` 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": [
@@ -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",
@@ -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.<id>]` 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": [
+21 -287
View File
@@ -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",
File diff suppressed because one or more lines are too long
@@ -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.<id>]` 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<ActivePermissionProfileModification>, };
@@ -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, };
@@ -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, };
@@ -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, };
@@ -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<HookMetadata>, warnings: Array<string>, errors: Array<HookErrorInfo>, };
@@ -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<string>, };
@@ -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<HooksListEntry>, };
@@ -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.
@@ -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, };
@@ -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<PermissionProfileModificationParams> | null, };
@@ -16,8 +16,8 @@ instructionSources: Array<AbsolutePathBuf>, 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};
@@ -16,8 +16,8 @@ instructionSources: Array<AbsolutePathBuf>, 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};
@@ -16,8 +16,8 @@ instructionSources: Array<AbsolutePathBuf>, 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};
@@ -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";
@@ -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(|| "<unknown>".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<ClientResponsePayload>),
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<ClientResponse> {
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<v1::InterruptConversationResponse> 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;
@@ -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(())
}
+216 -32
View File
@@ -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<PermissionProfile> 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.<id>]` profile.
pub id: String,
/// Parent profile identifier once permissions profiles support
/// inheritance. This is currently always `null`.
#[serde(default)]
pub extends: Option<String>,
/// Bounded user-requested modifications applied on top of the named
/// profile, if any.
#[serde(default)]
pub modifications: Vec<ActivePermissionProfileModification>,
}
#[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<CoreActivePermissionProfileModification> for ActivePermissionProfileModification {
fn from(value: CoreActivePermissionProfileModification) -> Self {
match value {
CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => {
Self::AdditionalWritableRoot { path }
}
}
}
}
impl From<ActivePermissionProfileModification> for CoreActivePermissionProfileModification {
fn from(value: ActivePermissionProfileModification) -> Self {
match value {
ActivePermissionProfileModification::AdditionalWritableRoot { path } => {
Self::AdditionalWritableRoot { path }
}
}
}
}
impl From<CoreActivePermissionProfile> 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<ActivePermissionProfile> 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<Vec<PermissionProfileModificationParams>>,
},
}
#[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<ApprovalsReviewer>,
#[ts(optional = nullable)]
pub sandbox: Option<SandboxMode>,
/// 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<PermissionProfile>,
pub permissions: Option<PermissionProfileSelectionParams>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, JsonValue>>,
#[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<PermissionProfile>,
/// Named or implicit built-in profile that produced the active
/// permissions, when known.
#[experimental("thread/start.activePermissionProfile")]
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
}
@@ -3605,11 +3720,12 @@ pub struct ThreadResumeParams {
pub approvals_reviewer: Option<ApprovalsReviewer>,
#[ts(optional = nullable)]
pub sandbox: Option<SandboxMode>,
/// 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<PermissionProfile>,
pub permissions: Option<PermissionProfileSelectionParams>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, serde_json::Value>>,
#[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<PermissionProfile>,
/// Named or implicit built-in profile that produced the active
/// permissions, when known.
#[experimental("thread/resume.activePermissionProfile")]
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
}
@@ -3702,11 +3824,12 @@ pub struct ThreadForkParams {
pub approvals_reviewer: Option<ApprovalsReviewer>,
#[ts(optional = nullable)]
pub sandbox: Option<SandboxMode>,
/// 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<PermissionProfile>,
pub permissions: Option<PermissionProfileSelectionParams>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, serde_json::Value>>,
#[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<PermissionProfile>,
/// Named or implicit built-in profile that produced the active
/// permissions, when known.
#[experimental("thread/fork.activePermissionProfile")]
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
}
@@ -4354,6 +4483,22 @@ pub struct SkillsListResponse {
pub data: Vec<SkillsListEntry>,
}
#[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<PathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct HooksListResponse {
pub data: Vec<HooksListEntry>,
}
#[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<SkillErrorInfo>,
}
#[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<HookMetadata>,
pub warnings: Vec<String>,
pub errors: Vec<HookErrorInfo>,
}
#[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<String>,
pub command: Option<String>,
pub timeout_sec: u64,
pub status_message: Option<String>,
pub source_path: AbsolutePathBuf,
pub source: HookSource,
pub plugin_id: Option<String>,
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<SandboxPolicy>,
/// 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<PermissionProfile>,
pub permissions: Option<PermissionProfileSelectionParams>,
/// Override the model for this turn and subsequent turns.
#[ts(optional = nullable)]
pub model: Option<String>,
@@ -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,
+3 -1
View File
@@ -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")
+1
View File
@@ -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 }
+51 -10
View File
@@ -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 <feature_name> > 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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -144,8 +144,24 @@ impl ExternalAgentConfigService {
Ok(items)
}
pub(crate) fn detect_recent_sessions(&self) -> io::Result<Vec<ExternalAgentSessionMigration>> {
detect_recent_sessions(&self.external_agent_home, &self.codex_home)
pub(crate) fn external_agent_session_source_path(
&self,
path: &Path,
) -> io::Result<Option<PathBuf>> {
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(
+3
View File
@@ -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,
@@ -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<Semaphore>,
}
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<Vec<CoreSessionMigration>, 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<Vec<PendingSessionImport>, JSONRPCErrorError> {
) -> Result<Vec<CoreSessionMigration>, JSONRPCErrorError> {
let sessions = params
.migration_items
.iter()
@@ -163,18 +159,32 @@ impl ExternalAgentConfigApi {
title: session.title,
})
.collect::<Vec<_>>();
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<CoreSessionMigration>,
) -> Vec<PendingSessionImport> {
prepare_validated_session_imports(&self.codex_home, sessions)
}
pub(crate) fn session_import_permits(&self) -> Arc<Semaphore> {
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()
))
}
+91 -45
View File
@@ -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<T: serde::Serialize>(
async fn handle_config_mutation_result<T>(
&self,
request_id: ConnectionRequestId,
result: std::result::Result<T, JSONRPCErrorError>,
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<ClientResponsePayload> + Send + 'static,
F: FnOnce(DeviceKeyApi) -> Fut + Send + 'static,
Fut: Future<Output = Result<R, JSONRPCErrorError>> + 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(&params)?;
.validate_pending_session_imports(&params)?;
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 {},
@@ -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,
+50 -19
View File
@@ -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<T: Serialize>(
&self,
request_id: ConnectionRequestId,
response: T,
) {
pub(crate) async fn send_response<T>(&self, request_id: ConnectionRequestId, response: T)
where
T: Into<ClientResponsePayload>,
{
self.outgoing.send_response(request_id, response).await;
}
@@ -482,21 +482,40 @@ impl OutgoingMessageSender {
}
}
pub(crate) async fn send_response<T: Serialize>(
pub(crate) async fn send_response<T>(&self, request_id: ConnectionRequestId, response: T)
where
T: Into<ClientResponsePayload>,
{
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<T, E>,
) where
T: Serialize,
T: Into<ClientResponsePayload>,
E: Into<JSONRPCErrorError>,
{
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);
+1 -4
View File
@@ -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<ConnectionRequestId>;
pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
@@ -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<i64> {
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,
@@ -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
}
})))
}
@@ -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)]
@@ -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;
@@ -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: "<EXTERNAL SESSION IMPORTED>".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 {
@@ -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(())
}
@@ -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;
@@ -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;
@@ -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;
@@ -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,
@@ -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,
@@ -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::<ThreadStartResponse>(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),
+1 -1
View File
@@ -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 {
+1 -3
View File
@@ -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
+46
View File
@@ -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,
+16 -7
View File
@@ -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<String>,
/// 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 {
+36 -4
View File
@@ -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");
@@ -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.
@@ -89,6 +89,7 @@ pub struct ConfigRequirements {
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
/// 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<BTreeMap<String, McpServerRequirement>>,
}
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<FeatureRequirementsToml>,
pub hooks: Option<ManagedHooksRequirementsToml>,
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
pub plugins: Option<BTreeMap<String, PluginRequirementsToml>>,
pub apps: Option<AppsRequirementsToml>,
pub rules: Option<RequirementsExecPolicyToml>,
pub enforce_residency: Option<ResidencyRequirement>,
@@ -679,6 +693,7 @@ pub struct ConfigRequirementsWithSources {
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
pub apps: Option<Sourced<AppsRequirementsToml>>,
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
@@ -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<ConfigRequirementsWithSources> for ConfigRequirements {
feature_requirements,
hooks,
mcp_servers,
plugins,
apps: _apps,
rules,
enforce_residency,
@@ -1133,6 +1157,7 @@ impl TryFrom<ConfigRequirementsWithSources> 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#"
+1
View File
@@ -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;
+9
View File
@@ -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::<codex_features::FeatureToml<
codex_features::AppsMcpPathOverrideConfigToml,
>>(),
);
continue;
}
validation
.properties
.insert(feature.key.to_string(), schema_gen.subschema_for::<bool>());
+44
View File
@@ -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<String, PluginMcpServerConfig>,
}
/// 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<AppToolApproval>,
/// Explicit allow-list of tools exposed from this server.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled_tools: Option<Vec<String>>,
/// 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<Vec<String>>,
/// Per-tool approval settings keyed by tool name.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tools: HashMap<String, McpServerToolConfig>,
}
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)]
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "core-api",
crate_name = "codex_core_api",
)
+26
View File
@@ -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 }
+71
View File
@@ -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;
+16
View File
@@ -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",
];
+38 -9
View File
@@ -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<String, PluginConfig>,
store: &PluginStore,
restriction_product: Option<Product>,
plugin_hooks_enabled: bool,
) -> PluginLoadOutcome<McpServerConfig> {
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<Product>,
skill_config_rules: &SkillConfigRules,
plugin_hooks_enabled: bool,
) -> LoadedPlugin<McpServerConfig> {
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<SkillMetadata>,
+39 -85
View File
@@ -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<RemotePluginDetail, RemotePluginCatalogError> {
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<RemotePluginDetail>,
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(
+76
View File
@@ -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"
},
+5
View File
@@ -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<ActivePermissionProfile>,
pub cwd: AbsolutePathBuf,
pub ephemeral: bool,
pub reasoning_effort: Option<ReasoningEffort>,
@@ -75,6 +77,7 @@ pub struct CodexThreadTurnContextOverrides {
pub approvals_reviewer: Option<ApprovalsReviewer>,
pub sandbox_policy: Option<SandboxPolicy>,
pub permission_profile: Option<PermissionProfile>,
pub active_permission_profile: Option<ActivePermissionProfile>,
pub windows_sandbox_level: Option<WindowsSandboxLevel>,
pub model: Option<String>,
pub effort: Option<Option<ReasoningEffort>>,
@@ -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,
@@ -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,
+453
View File
@@ -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<String, (bool, Option<McpServerDisabledReason>)>>(),
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<String, (bool, Option<McpServerDisabledReason>)>>(),
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()?;
+191 -15
View File
@@ -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<PermissionProfile>,
/// Named or implicit built-in profile selected by config, rather than an
/// ad-hoc override.
pub active_permission_profile: Option<ActivePermissionProfile>,
/// Effective network configuration applied to all spawned processes.
pub network: Option<NetworkProxySpec>,
/// 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<ActivePermissionProfile> {
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<ActivePermissionProfile>,
) -> 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<String>,
/// 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<String, McpServerConfig>,
plugin_requirements: Option<&Sourced<BTreeMap<String, PluginRequirementsToml>>>,
) {
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<String, McpServerConfig>,
mcp_requirements: Option<&Sourced<BTreeMap<String, McpServerRequirement>>>,
@@ -1201,7 +1280,7 @@ fn apply_requirement_constrained_value<T>(
configured_value: T,
constrained_value: &mut ConstrainedWithSource<T>,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<()>
) -> std::io::Result<bool>
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<ApprovalsReviewer>,
pub sandbox_mode: Option<SandboxMode>,
pub permission_profile: Option<PermissionProfile>,
pub default_permissions: Option<String>,
pub model_provider: Option<String>,
pub service_tier: Option<Option<ServiceTier>>,
pub config_profile: Option<String>,
@@ -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<WebSearchMode>,
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 {
+400 -10
View File
@@ -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<GuardianReviewSession>,
@@ -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<codex_network_proxy::NetworkProxyConfig>,
@@ -944,16 +984,18 @@ async fn run_before_review_deadline_with_cancel<T>(
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<Event>,
async_channel::Receiver<Submission>,
) {
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<i64>,
) -> 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,
&params,
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());
}
}
+109
View File
@@ -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(()));
+1
View File
@@ -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;
+96 -15
View File
@@ -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<Option<AbsolutePathBuf>> {
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(),
+171 -4
View File
@@ -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;
+1 -12
View File
@@ -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,
@@ -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<_>>(),
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");
+61 -10
View File
@@ -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<Option<CachedFeaturedPluginIds>>,
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
cached_enabled_outcome: RwLock<Option<CachedPluginLoadOutcome>>,
// 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<Option<Vec<RemoteInstalledPlugin>>>,
@@ -369,6 +370,13 @@ pub struct PluginsManager {
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
}
#[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<AbsolutePathBuf> {
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<AbsolutePathBuf> {
self.plugins_for_layer_stack(
config_layer_stack,
config,
config.features.enabled(Feature::PluginHooks),
)
.await
.effective_skill_roots()
}
fn cached_enabled_outcome(&self) -> Option<PluginLoadOutcome> {
fn cached_enabled_outcome(
&self,
config_version: &str,
plugin_hooks_enabled: bool,
) -> Option<PluginLoadOutcome> {
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()),
}
}

Some files were not shown because too many files have changed in this diff Show More