Merge remote-tracking branch 'origin/main' into pakrym/pr-15695-merge-main

# Conflicts:
#	codex-rs/core/src/tools/spec_tests.rs
This commit is contained in:
pakrym-oai
2026-03-25 08:24:15 -07:00
46 changed files with 1729 additions and 297 deletions
@@ -524,6 +524,21 @@
],
"type": "object"
},
"ExperimentalFeatureEnablementSetParams": {
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.",
"type": "object"
}
},
"required": [
"enablement"
],
"type": "object"
},
"ExperimentalFeatureListParams": {
"properties": {
"cursor": {
@@ -4294,6 +4309,30 @@
"title": "ExperimentalFeature/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"experimentalFeature/enablement/set"
],
"title": "ExperimentalFeature/enablement/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExperimentalFeatureEnablementSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExperimentalFeature/enablement/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1151,6 +1151,30 @@
"title": "ExperimentalFeature/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"experimentalFeature/enablement/set"
],
"title": "ExperimentalFeature/enablement/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ExperimentalFeatureEnablementSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExperimentalFeature/enablement/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -7249,6 +7273,40 @@
],
"type": "object"
},
"ExperimentalFeatureEnablementSetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetParams",
"type": "object"
},
"ExperimentalFeatureEnablementSetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Feature enablement entries updated by this request.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetResponse",
"type": "object"
},
"ExperimentalFeatureListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -1681,6 +1681,30 @@
"title": "ExperimentalFeature/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"experimentalFeature/enablement/set"
],
"title": "ExperimentalFeature/enablement/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExperimentalFeatureEnablementSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExperimentalFeature/enablement/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3809,6 +3833,40 @@
],
"type": "object"
},
"ExperimentalFeatureEnablementSetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetParams",
"type": "object"
},
"ExperimentalFeatureEnablementSetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Feature enablement entries updated by this request.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetResponse",
"type": "object"
},
"ExperimentalFeatureListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetParams",
"type": "object"
}
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Feature enablement entries updated by this request.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
// 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 ExperimentalFeatureEnablementSetParams = {
/**
* Process-wide runtime feature enablement keyed by canonical feature name.
*
* Only named features are updated. Omitted features are left unchanged.
* Send an empty map for a no-op.
*/
enablement: { [key in string]?: boolean }, };
@@ -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 ExperimentalFeatureEnablementSetResponse = {
/**
* Feature enablement entries updated by this request.
*/
enablement: { [key in string]?: boolean }, };
@@ -81,6 +81,8 @@ export type { DynamicToolSpec } from "./DynamicToolSpec";
export type { ErrorNotification } from "./ErrorNotification";
export type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
export type { ExperimentalFeature } from "./ExperimentalFeature";
export type { ExperimentalFeatureEnablementSetParams } from "./ExperimentalFeatureEnablementSetParams";
export type { ExperimentalFeatureEnablementSetResponse } from "./ExperimentalFeatureEnablementSetResponse";
export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams";
export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse";
export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage";
@@ -392,6 +392,10 @@ client_request_definitions! {
params: v2::ExperimentalFeatureListParams,
response: v2::ExperimentalFeatureListResponse,
},
ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" {
params: v2::ExperimentalFeatureEnablementSetParams,
response: v2::ExperimentalFeatureEnablementSetResponse,
},
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
@@ -1926,6 +1926,25 @@ pub struct ExperimentalFeatureListResponse {
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExperimentalFeatureEnablementSetParams {
/// Process-wide runtime feature enablement keyed by canonical feature name.
///
/// Only named features are updated. Omitted features are left unchanged.
/// Send an empty map for a no-op.
pub enablement: std::collections::BTreeMap<String, bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExperimentalFeatureEnablementSetResponse {
/// Feature enablement entries updated by this request.
pub enablement: std::collections::BTreeMap<String, bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1
View File
@@ -164,6 +164,7 @@ Example with notification opt-out:
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). 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.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
@@ -1,6 +1,7 @@
use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::command_exec::CommandExecManager;
use crate::command_exec::StartCommandExecParams;
use crate::config_api::apply_runtime_feature_enablement;
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
@@ -283,6 +284,7 @@ use codex_state::ThreadMetadataBuilder;
use codex_state::log_db::LogDbLayer;
use codex_utils_json_to_toml::json_to_toml;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
@@ -371,7 +373,8 @@ pub(crate) struct CodexMessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
cli_overrides: Vec<(String, TomlValue)>,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
@@ -422,7 +425,8 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) arg0_paths: Arg0DispatchPaths,
pub(crate) config: Arc<Config>,
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
pub(crate) cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
pub(crate) runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
pub(crate) feedback: CodexFeedback,
pub(crate) log_db: Option<LogDbLayer>,
@@ -486,6 +490,7 @@ impl CodexMessageProcessor {
arg0_paths,
config,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
feedback,
log_db,
@@ -497,6 +502,7 @@ impl CodexMessageProcessor {
arg0_paths,
config,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
active_login: Arc::new(Mutex::new(None)),
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
@@ -517,7 +523,7 @@ impl CodexMessageProcessor {
) -> Result<Config, JSONRPCErrorError> {
let cloud_requirements = self.current_cloud_requirements();
let mut config = codex_core::config::ConfigBuilder::default()
.cli_overrides(self.cli_overrides.clone())
.cli_overrides(self.current_cli_overrides())
.fallback_cwd(fallback_cwd)
.cloud_requirements(cloud_requirements)
.build()
@@ -527,6 +533,7 @@ impl CodexMessageProcessor {
message: format!("failed to reload config: {err}"),
data: None,
})?;
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone();
Ok(config)
@@ -539,6 +546,20 @@ impl CodexMessageProcessor {
.unwrap_or_default()
}
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
self.runtime_feature_enablement
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
/// If a client sends `developer_instructions: null` during a mode switch,
/// use the built-in instructions for that mode.
fn normalize_turn_start_collaboration_mode(
@@ -884,7 +905,8 @@ impl CodexMessageProcessor {
}
ClientRequest::ConfigRead { .. }
| ClientRequest::ConfigValueWrite { .. }
| ClientRequest::ConfigBatchWrite { .. } => {
| ClientRequest::ConfigBatchWrite { .. }
| ClientRequest::ExperimentalFeatureEnablementSet { .. } => {
warn!("Config request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::FsReadFile { .. }
@@ -1085,7 +1107,7 @@ impl CodexMessageProcessor {
let cloud_requirements = self.cloud_requirements.clone();
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
let codex_home = self.config.codex_home.clone();
let cli_overrides = self.cli_overrides.clone();
let cli_overrides = self.current_cli_overrides();
let auth_url = server.auth_url.clone();
tokio::spawn(async move {
let (success, error_msg) = match tokio::time::timeout(
@@ -1273,11 +1295,9 @@ impl CodexMessageProcessor {
self.config.chatgpt_base_url.clone(),
self.config.codex_home.clone(),
);
sync_default_client_residency_requirement(
&self.cli_overrides,
self.cloud_requirements.as_ref(),
)
.await;
let cli_overrides = self.current_cli_overrides();
sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref())
.await;
self.outgoing
.send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {})
@@ -1892,8 +1912,8 @@ impl CodexMessageProcessor {
personality,
);
typesafe_overrides.ephemeral = ephemeral;
let cli_overrides = self.cli_overrides.clone();
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let listener_task_context = ListenerTaskContext {
thread_manager: Arc::clone(&self.thread_manager),
thread_state_manager: self.thread_state_manager.clone(),
@@ -1903,10 +1923,12 @@ impl CodexMessageProcessor {
codex_home: self.config.codex_home.clone(),
};
let request_trace = request_context.request_trace();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let thread_start_task = async move {
Self::thread_start_task(
listener_task_context,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
request_id,
config,
@@ -1979,6 +2001,7 @@ impl CodexMessageProcessor {
async fn thread_start_task(
listener_task_context: ListenerTaskContext,
cli_overrides: Vec<(String, TomlValue)>,
runtime_feature_enablement: BTreeMap<String, bool>,
cloud_requirements: CloudRequirementsLoader,
request_id: ConnectionRequestId,
config_overrides: Option<HashMap<String, serde_json::Value>>,
@@ -1995,6 +2018,7 @@ impl CodexMessageProcessor {
typesafe_overrides,
&cloud_requirements,
&listener_task_context.codex_home,
&runtime_feature_enablement,
)
.await
{
@@ -3497,13 +3521,16 @@ impl CodexMessageProcessor {
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let config = match derive_config_for_cwd(
&self.cli_overrides,
&cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
&cloud_requirements,
&self.config.codex_home,
&runtime_feature_enablement,
)
.await
{
@@ -4039,13 +4066,16 @@ impl CodexMessageProcessor {
typesafe_overrides.ephemeral = ephemeral.then_some(true);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let config = match derive_config_for_cwd(
&self.cli_overrides,
&cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
&cloud_requirements,
&self.config.codex_home,
&runtime_feature_enablement,
)
.await
{
@@ -7224,12 +7254,13 @@ impl CodexMessageProcessor {
WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated,
};
let config = Arc::clone(&self.config);
let cli_overrides = self.cli_overrides.clone();
let cloud_requirements = self.current_cloud_requirements();
let command_cwd = params
.cwd
.map(PathBuf::from)
.unwrap_or_else(|| config.cwd.clone());
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let outgoing = Arc::clone(&self.outgoing);
let connection_id = request_id.connection_id;
@@ -7244,6 +7275,7 @@ impl CodexMessageProcessor {
Some(command_cwd.clone()),
&cloud_requirements,
&config.codex_home,
&runtime_feature_enablement,
)
.await;
let setup_result = match derived_config {
@@ -7873,6 +7905,7 @@ async fn derive_config_from_params(
typesafe_overrides: ConfigOverrides,
cloud_requirements: &CloudRequirementsLoader,
codex_home: &Path,
runtime_feature_enablement: &BTreeMap<String, bool>,
) -> std::io::Result<Config> {
let merged_cli_overrides = cli_overrides
.iter()
@@ -7885,13 +7918,15 @@ async fn derive_config_from_params(
)
.collect::<Vec<_>>();
codex_core::config::ConfigBuilder::default()
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.cli_overrides(merged_cli_overrides)
.harness_overrides(typesafe_overrides)
.cloud_requirements(cloud_requirements.clone())
.build()
.await
.await?;
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
Ok(config)
}
async fn derive_config_for_cwd(
@@ -7901,6 +7936,7 @@ async fn derive_config_for_cwd(
cwd: Option<PathBuf>,
cloud_requirements: &CloudRequirementsLoader,
codex_home: &Path,
runtime_feature_enablement: &BTreeMap<String, bool>,
) -> std::io::Result<Config> {
let merged_cli_overrides = cli_overrides
.iter()
@@ -7913,14 +7949,16 @@ async fn derive_config_for_cwd(
)
.collect::<Vec<_>>();
codex_core::config::ConfigBuilder::default()
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.cli_overrides(merged_cli_overrides)
.harness_overrides(typesafe_overrides)
.fallback_cwd(cwd)
.cloud_requirements(cloud_requirements.clone())
.build()
.await
.await?;
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
Ok(config)
}
async fn read_history_cwd_from_state_db(
+30 -30
View File
@@ -734,21 +734,21 @@ mod tests {
access: ReadOnlyAccess::FullAccess,
network_access: false,
};
ExecRequest {
command: vec!["cmd".to_string()],
cwd: PathBuf::from("."),
env: HashMap::new(),
network: None,
expiration: ExecExpiration::DefaultTimeout,
capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool,
sandbox: SandboxType::WindowsRestrictedToken,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
arg0: None,
}
ExecRequest::new(
vec!["cmd".to_string()],
PathBuf::from("."),
HashMap::new(),
/*network*/ None,
ExecExpiration::DefaultTimeout,
codex_core::exec::ExecCapturePolicy::ShellTool,
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
sandbox_policy.clone(),
FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
/*arg0*/ None,
)
}
#[tokio::test]
@@ -846,21 +846,21 @@ mod tests {
outgoing: Arc::new(OutgoingMessageSender::new(tx)),
request_id: request_id.clone(),
process_id: Some("proc-100".to_string()),
exec_request: ExecRequest {
command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()],
cwd: PathBuf::from("."),
env: HashMap::new(),
network: None,
expiration: ExecExpiration::Cancellation(CancellationToken::new()),
capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool,
sandbox: SandboxType::None,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
arg0: None,
},
exec_request: ExecRequest::new(
vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()],
PathBuf::from("."),
HashMap::new(),
/*network*/ None,
ExecExpiration::Cancellation(CancellationToken::new()),
codex_core::exec::ExecCapturePolicy::ShellTool,
SandboxType::None,
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
sandbox_policy.clone(),
FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
/*arg0*/ None,
),
started_network_proxy: None,
tty: false,
stream_stdin: false,
+259 -11
View File
@@ -9,11 +9,14 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::SandboxMode;
use codex_core::AnalyticsEventsClient;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::ConfigService;
use codex_core::config::ConfigServiceError;
use codex_core::config_loader::CloudRequirementsLoader;
@@ -24,15 +27,27 @@ use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirem
use codex_core::plugins::PluginId;
use codex_core::plugins::collect_plugin_enabled_candidates;
use codex_core::plugins::installed_plugin_telemetry_metadata;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::Op;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use toml::Value as TomlValue;
use tracing::warn;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"plugins",
"tool_search",
"tool_suggest",
"tool_call_mcp_elicitation",
];
#[async_trait]
pub(crate) trait UserConfigReloader: Send + Sync {
async fn reload_user_config(&self);
@@ -56,7 +71,8 @@ impl UserConfigReloader for ThreadManager {
#[derive(Clone)]
pub(crate) struct ConfigApi {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
user_config_reloader: Arc<dyn UserConfigReloader>,
@@ -66,7 +82,8 @@ pub(crate) struct ConfigApi {
impl ConfigApi {
pub(crate) fn new(
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
user_config_reloader: Arc<dyn UserConfigReloader>,
@@ -75,6 +92,7 @@ impl ConfigApi {
Self {
codex_home,
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
user_config_reloader,
@@ -83,24 +101,87 @@ impl ConfigApi {
}
fn config_service(&self) -> ConfigService {
let cloud_requirements = self
.cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default();
ConfigService::new(
self.codex_home.clone(),
self.cli_overrides.clone(),
self.current_cli_overrides(),
self.loader_overrides.clone(),
cloud_requirements,
self.current_cloud_requirements(),
)
}
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
self.runtime_feature_enablement
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_cloud_requirements(&self) -> CloudRequirementsLoader {
self.cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.cli_overrides(self.current_cli_overrides())
.loader_overrides(self.loader_overrides.clone())
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
.build()
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to resolve feature override precedence: {err}"),
data: None,
})?;
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
Ok(config)
}
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
self.config_service().read(params).await.map_err(map_error)
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
let mut response = self
.config_service()
.read(params)
.await
.map_err(map_error)?;
let config = self.load_latest_config(fallback_cwd).await?;
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
let Some(feature) = feature_for_key(feature_key) else {
continue;
};
let features = response
.config
.additional
.entry("features".to_string())
.or_insert_with(|| json!({}));
if !features.is_object() {
*features = json!({});
}
if let Some(features) = features.as_object_mut() {
features.insert(
(*feature_key).to_string(),
json!(config.features.enabled(feature)),
);
}
}
Ok(response)
}
pub(crate) async fn config_requirements_read(
@@ -154,6 +235,68 @@ impl ConfigApi {
Ok(response)
}
pub(crate) async fn set_experimental_feature_enablement(
&self,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
let ExperimentalFeatureEnablementSetParams { enablement } = params;
for key in enablement.keys() {
if canonical_feature_for_key(key).is_some() {
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
continue;
}
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
),
data: None,
});
}
let message = if let Some(feature) = feature_for_key(key) {
format!(
"invalid feature enablement `{key}`: use canonical feature key `{}`",
feature.key()
)
} else {
format!("invalid feature enablement `{key}`")
};
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
});
}
if enablement.is_empty() {
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
}
{
let mut runtime_feature_enablement =
self.runtime_feature_enablement
.write()
.map_err(|_| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "failed to update feature enablement".to_string(),
data: None,
})?;
runtime_feature_enablement.extend(
enablement
.iter()
.map(|(name, enabled)| (name.clone(), *enabled)),
);
}
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.user_config_reloader.reload_user_config().await;
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
}
fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap<String, bool>) {
for (plugin_id, enabled) in pending_changes {
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
@@ -170,6 +313,49 @@ impl ConfigApi {
}
}
pub(crate) fn protected_feature_keys(
config_layer_stack: &codex_core::config_loader::ConfigLayerStack,
) -> BTreeSet<String> {
let mut protected_features = config_layer_stack
.effective_config()
.get("features")
.and_then(toml::Value::as_table)
.map(|features| features.keys().cloned().collect::<BTreeSet<_>>())
.unwrap_or_default();
if let Some(feature_requirements) = config_layer_stack
.requirements_toml()
.feature_requirements
.as_ref()
{
protected_features.extend(feature_requirements.entries.keys().cloned());
}
protected_features
}
pub(crate) fn apply_runtime_feature_enablement(
config: &mut Config,
runtime_feature_enablement: &BTreeMap<String, bool>,
) {
let protected_features = protected_feature_keys(&config.config_layer_stack);
for (name, enabled) in runtime_feature_enablement {
if protected_features.contains(name) {
continue;
}
let Some(feature) = feature_for_key(name) else {
continue;
};
if let Err(err) = config.features.set_enabled(feature, *enabled) {
warn!(
feature = name,
error = %err,
"failed to apply runtime feature enablement"
);
}
}
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
@@ -265,6 +451,7 @@ mod tests {
use super::*;
use codex_core::AnalyticsEventsClient;
use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml;
use codex_features::Feature;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -392,6 +579,66 @@ mod tests {
);
}
#[tokio::test]
async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() {
let codex_home = TempDir::new().expect("create temp dir");
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\napps = false\n",
)
.expect("write config");
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![(
"features.apps".to_string(),
TomlValue::Boolean(true),
)])
.build()
.await
.expect("load config");
apply_runtime_feature_enablement(
&mut config,
&BTreeMap::from([("apps".to_string(), false)]),
);
assert!(config.features.enabled(Feature::Apps));
}
#[tokio::test]
async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![(
"features.apps".to_string(),
TomlValue::Boolean(true),
)])
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(
codex_core::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([("apps".to_string(), false)]),
},
),
..Default::default()
}))
}))
.build()
.await
.expect("load config");
apply_runtime_feature_enablement(
&mut config,
&BTreeMap::from([("apps".to_string(), true)]),
);
assert!(!config.features.enabled(Feature::Apps));
}
#[tokio::test]
async fn batch_write_reloads_user_config_when_requested() {
let codex_home = TempDir::new().expect("create temp dir");
@@ -406,7 +653,8 @@ mod tests {
);
let config_api = ConfigApi::new(
codex_home.path().to_path_buf(),
Vec::new(),
Arc::new(RwLock::new(Vec::new())),
Arc::new(RwLock::new(BTreeMap::new())),
LoaderOverrides::default(),
Arc::new(RwLock::new(CloudRequirementsLoader::default())),
reloader.clone(),
+40 -1
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
@@ -30,6 +31,7 @@ use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::FsCopyParams;
@@ -226,6 +228,8 @@ impl MessageProcessor {
.plugins_manager()
.set_analytics_events_client(analytics_events_client.clone());
let cli_overrides = Arc::new(RwLock::new(cli_overrides));
let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new()));
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
auth_manager: auth_manager.clone(),
@@ -234,6 +238,7 @@ impl MessageProcessor {
arg0_paths,
config: Arc::clone(&config),
cli_overrides: cli_overrides.clone(),
runtime_feature_enablement: runtime_feature_enablement.clone(),
cloud_requirements: cloud_requirements.clone(),
feedback,
log_db,
@@ -246,6 +251,7 @@ impl MessageProcessor {
let config_api = ConfigApi::new(
config.codex_home.clone(),
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
thread_manager,
@@ -700,6 +706,16 @@ impl MessageProcessor {
)
.await;
}
ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => {
self.handle_experimental_feature_enablement_set(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigRequirementsRead {
request_id,
params: _,
@@ -848,7 +864,30 @@ impl MessageProcessor {
request_id: ConnectionRequestId,
params: ConfigBatchWriteParams,
) {
match self.config_api.batch_write(params).await {
self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await)
.await;
}
async fn handle_experimental_feature_enablement_set(
&self,
request_id: ConnectionRequestId,
params: ExperimentalFeatureEnablementSetParams,
) {
self.handle_config_mutation_result(
request_id,
self.config_api
.set_experimental_feature_enablement(params)
.await,
)
.await;
}
async fn handle_config_mutation_result<T: serde::Serialize>(
&self,
request_id: ConnectionRequestId,
result: std::result::Result<T, JSONRPCErrorError>,
) {
match result {
Ok(response) => {
self.codex_message_processor.clear_plugin_related_caches();
self.codex_message_processor
@@ -465,6 +465,16 @@ impl McpProcess {
self.send_request("experimentalFeature/list", params).await
}
/// Send an `experimentalFeature/enablement/set` JSON-RPC request.
pub async fn send_experimental_feature_enablement_set_request(
&mut self,
params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("experimentalFeature/enablement/set", params)
.await
}
/// Send an `app/list` JSON-RPC request.
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -3,16 +3,24 @@ use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::ExperimentalFeatureListResponse;
use codex_app_server_protocol::ExperimentalFeatureStage;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_core::config::ConfigBuilder;
use codex_features::FEATURES;
use codex_features::Stage;
use pretty_assertions::assert_eq;
use serde::de::DeserializeOwned;
use serde_json::json;
use std::collections::BTreeMap;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -34,13 +42,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
.send_experimental_feature_list_request(ExperimentalFeatureListParams::default())
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let actual = to_response::<ExperimentalFeatureListResponse>(response)?;
let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?;
let expected_data = FEATURES
.iter()
.map(|spec| {
@@ -82,3 +84,240 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
assert_eq!(actual, expected);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads()
-> Result<()> {
let codex_home = TempDir::new()?;
let project_cwd = codex_home.path().join("project");
std::fs::create_dir_all(&project_cwd)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let actual =
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([("apps".to_string(), true)]),
}
);
for cwd in [None, Some(project_cwd.display().to_string())] {
let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
Some(&json!(true))
);
}
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\napps = false\n",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let actual =
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([("apps".to_string(), true)]),
}
);
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
Some(&json!(false))
);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
let actual = set_experimental_feature_enablement(
&mut mcp,
BTreeMap::from([
("plugins".to_string(), true),
("tool_search".to_string(), true),
("tool_suggest".to_string(), true),
("tool_call_mcp_elicitation".to_string(), false),
]),
)
.await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([
("plugins".to_string(), true),
("tool_search".to_string(), true),
("tool_suggest".to_string(), true),
("tool_call_mcp_elicitation".to_string(), false),
]),
}
);
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("plugins")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("tool_search")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("tool_suggest")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("tool_call_mcp_elicitation")),
Some(&json!(false))
);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::new(),
}
);
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
Some(&json!(true))
);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
enablement: BTreeMap::from([("personality".to_string(), true)]),
})
.await?;
let JSONRPCError { error, .. } = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.code, -32600);
assert!(
error
.message
.contains("unsupported feature enablement `personality`"),
"{}",
error.message
);
assert!(
error
.message
.contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"),
"{}",
error.message
);
Ok(())
}
async fn set_experimental_feature_enablement(
mcp: &mut McpProcess,
enablement: BTreeMap<String, bool>,
) -> Result<ExperimentalFeatureEnablementSetResponse> {
let request_id = mcp
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
enablement,
})
.await?;
read_response(mcp, request_id).await
}
async fn read_config(mcp: &mut McpProcess, cwd: Option<String>) -> Result<ConfigReadResponse> {
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd,
})
.await?;
read_response(mcp, request_id).await
}
async fn read_response<T: DeserializeOwned>(mcp: &mut McpProcess, request_id: i64) -> Result<T> {
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
+9 -5
View File
@@ -51,7 +51,6 @@ Expects the binary containing `codex-core` to run the equivalent of `codex sandb
Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux.
They can continue to use the legacy Landlock path when the split filesystem
policy is sandbox-equivalent to the legacy model after `cwd` resolution.
Split filesystem policies that need direct `FileSystemSandboxPolicy`
enforcement, such as read-only or denied carveouts under a broader writable
root, automatically route through bubblewrap. The legacy Landlock path is used
@@ -83,13 +82,18 @@ backend-managed system read roots required for basic execution, such as
`C:\ProgramData`. When it is `false`, those extra system roots are omitted.
The unelevated restricted-token backend still supports the legacy full-read
Windows model only. Restricted read-only policies continue to fail closed there
instead of running with weaker read enforcement.
Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also
supports a narrow split-filesystem subset: full-read split policies whose
writable roots still match the legacy `WorkspaceWrite` root set, but add extra
read-only carveouts under those writable roots.
New `[permissions]` / split filesystem policies remain supported on Windows
only when they round-trip through the legacy `SandboxPolicy` model without
changing semantics. Richer split-only carveouts still fail closed instead of
running with weaker enforcement.
changing semantics. Policies that would require direct read restriction,
explicit unreadable carveouts, reopened writable descendants under read-only
carveouts, different writable root sets, or split carveout support in the
elevated setup/runner backend still fail closed instead of running with weaker
enforcement.
### All Platforms
+6
View File
@@ -485,6 +485,9 @@
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"tool_search": {
"type": "boolean"
},
"tool_suggest": {
"type": "boolean"
},
@@ -2099,6 +2102,9 @@
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"tool_search": {
"type": "boolean"
},
"tool_suggest": {
"type": "boolean"
},
+210 -58
View File
@@ -1,6 +1,7 @@
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::io;
use std::path::Path;
@@ -32,7 +33,7 @@ use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
use codex_network_proxy::NetworkProxy;
#[cfg(any(target_os = "windows", test))]
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
@@ -41,6 +42,7 @@ use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use codex_utils_pty::process_group::kill_child_process_group;
@@ -91,6 +93,17 @@ pub struct ExecParams {
pub arg0: Option<String>,
}
/// Extra filesystem deny-write carveouts for the non-elevated Windows
/// restricted-token backend.
///
/// These are applied on top of the legacy `WorkspaceWrite` allow set, so we
/// can support a narrow split-policy subset without changing legacy Windows
/// sandbox semantics.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WindowsRestrictedTokenFilesystemOverlay {
pub(crate) additional_deny_write_paths: Vec<AbsolutePathBuf>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ExecCapturePolicy {
/// Shell-like execs keep the historical output cap and timeout behavior.
@@ -269,7 +282,7 @@ pub fn build_exec_request(
expiration,
capture_policy,
};
let exec_req = manager
let mut exec_req = manager
.transform(SandboxTransformRequest {
command,
policy: sandbox_policy,
@@ -288,6 +301,16 @@ pub fn build_exec_request(
})
.map(|request| ExecRequest::from_sandbox_exec_request(request, options))
.map_err(CodexErr::from)?;
exec_req.windows_restricted_token_filesystem_overlay =
resolve_windows_restricted_token_filesystem_overlay(
exec_req.sandbox,
&exec_req.sandbox_policy,
&exec_req.file_system_sandbox_policy,
exec_req.network_sandbox_policy,
sandbox_cwd,
exec_req.windows_sandbox_level,
)
.map_err(CodexErr::UnsupportedOperation)?;
Ok(exec_req)
}
@@ -310,6 +333,7 @@ pub(crate) async fn execute_exec_request(
sandbox_policy: _sandbox_policy_from_env,
file_system_sandbox_policy,
network_sandbox_policy,
windows_restricted_token_filesystem_overlay,
arg0,
} = exec_request;
let _ = _sandbox_policy_from_env;
@@ -334,6 +358,7 @@ pub(crate) async fn execute_exec_request(
sandbox,
sandbox_policy,
&file_system_sandbox_policy,
windows_restricted_token_filesystem_overlay.as_ref(),
network_sandbox_policy,
stdout_stream,
after_spawn,
@@ -413,11 +438,11 @@ fn record_windows_sandbox_spawn_failure(
async fn exec_windows_sandbox(
params: ExecParams,
sandbox_policy: &SandboxPolicy,
windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>,
) -> Result<RawExecToolCallOutput> {
use crate::config::find_codex_home;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_windows_sandbox::run_windows_sandbox_capture;
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths;
let ExecParams {
command,
@@ -456,6 +481,15 @@ async fn exec_windows_sandbox(
let command_path = command.first().cloned();
let sandbox_level = windows_sandbox_level;
let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated);
let additional_deny_write_paths = windows_restricted_token_filesystem_overlay
.map(|overlay| {
overlay
.additional_deny_write_paths
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let spawn_res = tokio::task::spawn_blocking(move || {
if use_elevated {
run_windows_sandbox_capture_elevated(
@@ -469,7 +503,7 @@ async fn exec_windows_sandbox(
windows_sandbox_private_desktop,
)
} else {
run_windows_sandbox_capture(
run_windows_sandbox_capture_with_extra_deny_write_paths(
policy_str.as_str(),
&sandbox_cwd,
codex_home.as_ref(),
@@ -477,6 +511,7 @@ async fn exec_windows_sandbox(
&cwd,
env,
timeout_ms,
&additional_deny_write_paths,
windows_sandbox_private_desktop,
)
}
@@ -785,29 +820,25 @@ impl Default for ExecToolCallOutput {
}
}
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
#[allow(clippy::too_many_arguments)]
async fn exec(
params: ExecParams,
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
_sandbox: SandboxType,
_sandbox_policy: &SandboxPolicy,
_file_system_sandbox_policy: &FileSystemSandboxPolicy,
_windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>,
network_sandbox_policy: NetworkSandboxPolicy,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<RawExecToolCallOutput> {
#[cfg(target_os = "windows")]
if sandbox == SandboxType::WindowsRestrictedToken {
let support = windows_restricted_token_sandbox_support(
sandbox,
params.windows_sandbox_level,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
);
if let Some(reason) = support.unsupported_reason {
return Err(CodexErr::Io(io::Error::other(reason)));
}
return exec_windows_sandbox(params, sandbox_policy).await;
if _sandbox == SandboxType::WindowsRestrictedToken {
return exec_windows_sandbox(
params,
_sandbox_policy,
_windows_restricted_token_filesystem_overlay,
)
.await;
}
let ExecParams {
command,
@@ -852,54 +883,175 @@ async fn exec(
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[derive(Debug, PartialEq, Eq)]
struct WindowsRestrictedTokenSandboxSupport {
should_use: bool,
unsupported_reason: Option<String>,
}
#[cfg(any(target_os = "windows", test))]
fn windows_restricted_token_sandbox_support(
fn should_use_windows_restricted_token_sandbox(
sandbox: SandboxType,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
) -> WindowsRestrictedTokenSandboxSupport {
if sandbox != SandboxType::WindowsRestrictedToken {
return WindowsRestrictedTokenSandboxSupport {
should_use: false,
unsupported_reason: None,
};
}
// Windows currently reuses SandboxType::WindowsRestrictedToken for both
// the legacy restricted-token backend and the elevated setup/runner path.
// The sandbox level decides whether restricted read-only policies are
// supported.
let should_use = file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
) -> bool {
sandbox == SandboxType::WindowsRestrictedToken
&& file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
&& !matches!(
sandbox_policy,
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
)
&& (matches!(
windows_sandbox_level,
codex_protocol::config_types::WindowsSandboxLevel::Elevated
) || sandbox_policy.has_full_disk_read_access());
}
let unsupported_reason = if should_use {
None
} else {
Some(format!(
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn unsupported_windows_restricted_token_sandbox_reason(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
windows_sandbox_level: WindowsSandboxLevel,
) -> Option<String> {
resolve_windows_restricted_token_filesystem_overlay(
sandbox,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd,
windows_sandbox_level,
)
.err()
}
pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
sandbox: SandboxType,
sandbox_policy: &SandboxPolicy,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
sandbox_policy_cwd: &Path,
windows_sandbox_level: WindowsSandboxLevel,
) -> std::result::Result<Option<WindowsRestrictedTokenFilesystemOverlay>, String> {
if sandbox != SandboxType::WindowsRestrictedToken {
return Ok(None);
}
let needs_direct_runtime_enforcement = file_system_sandbox_policy
.needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd);
if should_use_windows_restricted_token_sandbox(
sandbox,
sandbox_policy,
file_system_sandbox_policy,
) && !needs_direct_runtime_enforcement
{
return Ok(None);
}
if !should_use_windows_restricted_token_sandbox(
sandbox,
sandbox_policy,
file_system_sandbox_policy,
) {
return Err(format!(
"windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed",
file_system_sandbox_policy.kind,
))
};
WindowsRestrictedTokenSandboxSupport {
should_use,
unsupported_reason,
));
}
if windows_sandbox_level != WindowsSandboxLevel::RestrictedToken {
return Err(
"windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed"
.to_string(),
);
}
if !file_system_sandbox_policy.has_full_disk_read_access() {
return Err(
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
.to_string(),
);
}
if !file_system_sandbox_policy
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
.is_empty()
{
return Err(
"windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
.to_string(),
);
}
let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
let split_writable_roots =
file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
let legacy_root_paths: BTreeSet<PathBuf> = legacy_writable_roots
.iter()
.map(|root| normalize_windows_overlay_path(root.root.as_path()))
.collect::<std::result::Result<_, _>>()?;
let split_root_paths: BTreeSet<PathBuf> = split_writable_roots
.iter()
.map(|root| normalize_windows_overlay_path(root.root.as_path()))
.collect::<std::result::Result<_, _>>()?;
if legacy_root_paths != split_root_paths {
return Err(
"windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed"
.to_string(),
);
}
for writable_root in &split_writable_roots {
for read_only_subpath in &writable_root.read_only_subpaths {
if split_writable_roots.iter().any(|candidate| {
candidate.root.as_path() != writable_root.root.as_path()
&& candidate
.root
.as_path()
.starts_with(read_only_subpath.as_path())
}) {
return Err(
"windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed"
.to_string(),
);
}
}
}
let mut additional_deny_write_paths = BTreeSet::new();
for split_root in &split_writable_roots {
let split_root_path = normalize_windows_overlay_path(split_root.root.as_path())?;
let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| {
normalize_windows_overlay_path(candidate.root.as_path())
.is_ok_and(|candidate_path| candidate_path == split_root_path)
}) else {
return Err(
"windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed"
.to_string(),
);
};
for read_only_subpath in &split_root.read_only_subpaths {
if !legacy_root
.read_only_subpaths
.iter()
.any(|candidate| candidate == read_only_subpath)
{
additional_deny_write_paths
.insert(normalize_windows_overlay_path(read_only_subpath.as_path())?);
}
}
}
if additional_deny_write_paths.is_empty() {
return Ok(None);
}
Ok(Some(WindowsRestrictedTokenFilesystemOverlay {
additional_deny_write_paths: additional_deny_write_paths
.into_iter()
.map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string()))
.collect::<std::result::Result<_, _>>()?,
}))
}
fn normalize_windows_overlay_path(path: &Path) -> std::result::Result<PathBuf, String> {
AbsolutePathBuf::from_absolute_path(dunce::simplified(path))
.map(AbsolutePathBuf::into_path_buf)
.map_err(|err| err.to_string())
}
/// Consumes the output of a child process according to the configured capture
+215 -91
View File
@@ -269,6 +269,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> {
SandboxType::None,
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
/*windows_restricted_token_filesystem_overlay*/ None,
NetworkSandboxPolicy::Enabled,
/*stdout_stream*/ None,
/*after_spawn*/ None,
@@ -308,6 +309,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p
SandboxType::None,
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
/*windows_restricted_token_filesystem_overlay*/ None,
NetworkSandboxPolicy::Enabled,
/*stdout_stream*/ None,
/*after_spawn*/ None,
@@ -376,42 +378,30 @@ fn windows_restricted_token_skips_external_sandbox_policies() {
let policy = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
assert_eq!(
windows_restricted_token_sandbox_support(
should_use_windows_restricted_token_sandbox(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
WindowsRestrictedTokenSandboxSupport {
should_use: false,
unsupported_reason: Some(
"windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
),
}
false
);
}
#[test]
fn windows_restricted_token_runs_for_legacy_restricted_policies() {
let policy = SandboxPolicy::new_read_only_policy();
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
assert_eq!(
windows_restricted_token_sandbox_support(
should_use_windows_restricted_token_sandbox(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
WindowsRestrictedTokenSandboxSupport {
should_use: true,
unsupported_reason: None,
}
true
);
}
@@ -421,70 +411,39 @@ fn windows_restricted_token_rejects_network_only_restrictions() {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let file_system_policy = FileSystemSandboxPolicy::unrestricted();
let sandbox_policy_cwd = std::env::current_dir().expect("cwd");
assert_eq!(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
WindowsRestrictedTokenSandboxSupport {
should_use: false,
unsupported_reason: Some(
"windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
&sandbox_policy_cwd,
WindowsSandboxLevel::RestrictedToken,
),
}
);
Some(
"windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
)
);
}
#[test]
fn windows_restricted_token_allows_legacy_restricted_policies() {
let policy = SandboxPolicy::new_read_only_policy();
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
windows_restricted_token_sandbox_support(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
),
WindowsRestrictedTokenSandboxSupport {
should_use: true,
unsupported_reason: None,
}
);
}
#[test]
fn windows_restricted_token_rejects_restricted_read_only_policies() {
let policy = SandboxPolicy::ReadOnly {
access: codex_protocol::protocol::ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![],
},
network_access: false,
};
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
let sandbox_policy_cwd = std::env::current_dir().expect("cwd");
assert_eq!(
windows_restricted_token_sandbox_support(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
&sandbox_policy_cwd,
WindowsSandboxLevel::RestrictedToken,
),
WindowsRestrictedTokenSandboxSupport {
should_use: false,
unsupported_reason: Some(
"windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ReadOnly { access: Restricted { include_platform_defaults: true, readable_roots: [] }, network_access: false }; refusing to run unsandboxed".to_string()
),
},
"restricted-token should fail closed for restricted read-only policies"
None
);
}
@@ -494,50 +453,213 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() {
writable_roots: vec![],
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
let sandbox_policy_cwd = std::env::current_dir().expect("cwd");
assert_eq!(
windows_restricted_token_sandbox_support(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
&sandbox_policy_cwd,
WindowsSandboxLevel::RestrictedToken,
),
WindowsRestrictedTokenSandboxSupport {
should_use: true,
unsupported_reason: None,
}
None
);
}
#[test]
fn windows_elevated_sandbox_allows_restricted_read_only_policies() {
let policy = SandboxPolicy::ReadOnly {
access: codex_protocol::protocol::ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: vec![],
},
fn windows_restricted_token_rejects_split_only_filesystem_policies() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let docs = temp_dir.path().join("docs");
std::fs::create_dir_all(&docs).expect("create docs");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path {
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
},
]);
assert_eq!(
windows_restricted_token_sandbox_support(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Elevated,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
temp_dir.path(),
WindowsSandboxLevel::RestrictedToken,
),
WindowsRestrictedTokenSandboxSupport {
should_use: true,
unsupported_reason: None,
Some(
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
.to_string()
)
);
}
#[test]
fn windows_restricted_token_rejects_root_write_read_only_carveouts() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let docs = temp_dir.path().join("docs");
std::fs::create_dir_all(&docs).expect("create docs");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
"elevated Windows sandbox should keep restricted read-only support enabled"
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path {
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
},
]);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
temp_dir.path(),
WindowsSandboxLevel::RestrictedToken,
),
Some(
"windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed"
.to_string()
)
);
}
#[test]
fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let cwd = dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir");
let docs = cwd.join("docs");
std::fs::create_dir_all(&docs).expect("create docs");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path {
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
},
]);
assert_eq!(
resolve_windows_restricted_token_filesystem_overlay(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
&cwd,
WindowsSandboxLevel::RestrictedToken,
),
Ok(Some(WindowsRestrictedTokenFilesystemOverlay {
additional_deny_write_paths: vec![
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
],
}))
);
}
#[test]
fn windows_elevated_rejects_split_write_read_carveouts() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let docs = temp_dir.path().join("docs");
std::fs::create_dir_all(&docs).expect("create docs");
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: codex_protocol::permissions::FileSystemAccessMode::Write,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Path {
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
.expect("absolute docs"),
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
},
]);
assert_eq!(
unsupported_windows_restricted_token_sandbox_reason(
SandboxType::WindowsRestrictedToken,
&policy,
&file_system_policy,
NetworkSandboxPolicy::Restricted,
temp_dir.path(),
WindowsSandboxLevel::Elevated,
),
Some(
"windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed"
.to_string()
)
);
}
@@ -549,7 +671,7 @@ fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions()
select_process_exec_tool_sandbox_type(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
WindowsSandboxLevel::Disabled,
codex_protocol::config_types::WindowsSandboxLevel::Disabled,
false,
),
expected
@@ -581,16 +703,17 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
"-c".to_string(),
"sleep 60 & echo $!; sleep 60".to_string(),
];
let cwd = std::env::current_dir()?;
let env: HashMap<String, String> = std::env::vars().collect();
let params = ExecParams {
command,
cwd: std::env::current_dir()?,
cwd,
expiration: 500.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env,
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
@@ -601,6 +724,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
SandboxType::None,
&SandboxPolicy::new_read_only_policy(),
&FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()),
None,
NetworkSandboxPolicy::Restricted,
None,
None,
@@ -648,7 +772,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
env,
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
+15 -4
View File
@@ -495,7 +495,7 @@ async fn maybe_request_mcp_tool_approval(
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
let approval_required = annotations.is_some_and(requires_mcp_tool_approval);
let approval_required = requires_mcp_tool_approval(annotations);
let mut monitor_reason = None;
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve
|| (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context));
@@ -1299,12 +1299,23 @@ async fn persist_codex_app_tool_approval(
.await
}
fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool {
if annotations.destructive_hint == Some(true) {
fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint);
if destructive_hint == Some(true) {
return true;
}
annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true)
let read_only_hint = annotations
.and_then(|annotations| annotations.read_only_hint)
.unwrap_or(false);
if read_only_hint {
return false;
}
destructive_hint.unwrap_or(true)
|| annotations
.and_then(|annotations| annotations.open_world_hint)
.unwrap_or(true)
}
async fn notify_mcp_tool_call_skip(
+83 -3
View File
@@ -64,19 +64,30 @@ fn prompt_options(
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), None);
assert_eq!(requires_mcp_tool_approval(&annotations), true);
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
}
#[test]
fn approval_required_when_read_only_false_and_open_world() {
let annotations = annotations(Some(false), None, Some(true));
assert_eq!(requires_mcp_tool_approval(&annotations), true);
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
}
#[test]
fn approval_required_when_destructive_even_if_read_only_true() {
let annotations = annotations(Some(true), Some(true), Some(true));
assert_eq!(requires_mcp_tool_approval(&annotations), true);
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
}
#[test]
fn approval_required_when_annotations_are_absent() {
assert_eq!(requires_mcp_tool_approval(None), true);
}
#[test]
fn approval_not_required_when_read_only_and_other_hints_are_absent() {
let annotations = annotations(Some(true), None, None);
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false);
}
#[test]
@@ -1067,6 +1078,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
);
}
#[tokio::test]
async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "steer-model",
"short_reason": "needs approval",
"rationale": "high-risk action",
"risk_score": 96,
"risk_level": "critical",
"evidence": [{
"message": "dangerous_tool",
"why": "high-risk action",
}],
})))
.expect(1)
.mount(&server)
.await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool: "dangerous_tool".to_string(),
arguments: Some(serde_json::json!({ "id": 1 })),
};
let metadata = McpToolApprovalMetadata {
annotations: None,
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Manage events".to_string()),
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
codex_apps_meta: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-3",
&invocation,
Some(&metadata),
AppToolApproval::Approve,
)
.await;
assert_eq!(
decision,
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
))
);
}
#[tokio::test]
async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() {
use wiremock::Mock;
@@ -44,6 +44,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
r#"[features]
plugins = false
"#,
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
+38
View File
@@ -11,6 +11,7 @@ use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallOutput;
use crate::exec::StdoutStream;
use crate::exec::WindowsRestrictedTokenFilesystemOverlay;
use crate::exec::execute_exec_request;
#[cfg(target_os = "macos")]
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
@@ -46,10 +47,46 @@ pub struct ExecRequest {
pub sandbox_policy: SandboxPolicy,
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
pub network_sandbox_policy: NetworkSandboxPolicy,
pub(crate) windows_restricted_token_filesystem_overlay:
Option<WindowsRestrictedTokenFilesystemOverlay>,
pub arg0: Option<String>,
}
impl ExecRequest {
#[allow(clippy::too_many_arguments)]
pub fn new(
command: Vec<String>,
cwd: PathBuf,
env: HashMap<String, String>,
network: Option<NetworkProxy>,
expiration: ExecExpiration,
capture_policy: ExecCapturePolicy,
sandbox: SandboxType,
windows_sandbox_level: WindowsSandboxLevel,
windows_sandbox_private_desktop: bool,
sandbox_policy: SandboxPolicy,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
arg0: Option<String>,
) -> Self {
Self {
command,
cwd,
env,
network,
expiration,
capture_policy,
sandbox,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
windows_restricted_token_filesystem_overlay: None,
arg0,
}
}
pub(crate) fn from_sandbox_exec_request(
request: SandboxExecRequest,
options: ExecOptions,
@@ -94,6 +131,7 @@ impl ExecRequest {
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
windows_restricted_token_filesystem_overlay: None,
arg0,
}
}
+1
View File
@@ -175,6 +175,7 @@ pub(crate) async fn execute_user_shell_command(
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
windows_restricted_token_filesystem_overlay: None,
arg0: None,
};
@@ -25,6 +25,8 @@ use crate::unified_exec::UnifiedExecProcessManager;
use crate::unified_exec::WriteStdinRequest;
use async_trait::async_trait;
use codex_features::Feature;
use codex_otel::SessionTelemetry;
use codex_otel::metrics::names::TOOL_CALL_UNIFIED_EXEC_METRIC;
use codex_protocol::models::PermissionProfile;
use serde::Deserialize;
use std::path::PathBuf;
@@ -260,6 +262,7 @@ impl ToolHandler for UnifiedExecHandler {
});
}
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
manager
.exec_command(
ExecCommandRequest {
@@ -323,6 +326,14 @@ impl ToolHandler for UnifiedExecHandler {
}
}
fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) {
session_telemetry.counter(
TOOL_CALL_UNIFIED_EXEC_METRIC,
/*inc*/ 1,
&[("tty", if tty { "true" } else { "false" })],
);
}
pub(crate) fn get_command(
args: &ExecCommandArgs,
session_shell: Arc<Shell>,
@@ -137,6 +137,7 @@ pub(super) async fn try_run_zsh_fork(
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
windows_restricted_token_filesystem_overlay: _windows_restricted_token_filesystem_overlay,
arg0,
} = sandbox_exec_request;
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
@@ -909,6 +910,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
sandbox_policy: self.sandbox_policy.clone(),
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
network_sandbox_policy: self.network_sandbox_policy,
windows_restricted_token_filesystem_overlay: None,
arg0: self.arg0.clone(),
},
/*stdout_stream*/ None,
+2 -1
View File
@@ -388,7 +388,8 @@ impl ToolsConfig {
let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_));
let include_default_mode_request_user_input =
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
let include_search_tool = model_info.supports_search_tool;
let include_search_tool =
model_info.supports_search_tool && features.enabled(Feature::ToolSearch);
let include_tool_suggest = include_search_tool
&& features.enabled(Feature::ToolSuggest)
&& features.enabled(Feature::Apps)
+28 -8
View File
@@ -1867,6 +1867,7 @@ async fn search_tool_description_lists_each_codex_apps_connector_once() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::ToolSearch);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -1985,7 +1986,7 @@ async fn search_tool_description_lists_each_codex_apps_connector_once() {
}
#[tokio::test]
async fn search_tool_requires_model_capability_only() {
async fn search_tool_requires_model_capability_and_feature_flag() {
let model_info = search_capable_model_info().await;
let app_tools = Some(HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
@@ -2021,6 +2022,22 @@ async fn search_tool_requires_model_capability_only() {
});
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
let mut features = Features::with_defaults();
features.enable(Feature::ToolSearch);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -2039,8 +2056,8 @@ async fn search_tool_requires_model_capability_only() {
async fn tool_suggest_is_not_registered_without_feature_flag() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::Plugins);
features.enable(Feature::ToolSearch);
features.disable(Feature::ToolSuggest);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -2083,12 +2100,11 @@ async fn tool_suggest_requires_apps_and_plugins_features() {
for disabled_feature in [Feature::Apps, Feature::Plugins] {
let mut features = Features::with_defaults();
features.enable(Feature::ToolSearch);
features.enable(Feature::ToolSuggest);
for feature in [Feature::Apps, Feature::Plugins] {
if feature != disabled_feature {
features.enable(feature);
}
}
features.enable(Feature::Apps);
features.enable(Feature::Plugins);
features.disable(disabled_feature);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -2121,6 +2137,7 @@ async fn search_tool_description_handles_no_enabled_apps() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::ToolSearch);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -2147,6 +2164,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::ToolSearch);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -2195,6 +2213,7 @@ async fn search_tool_registers_namespaced_app_tool_aliases() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::ToolSearch);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -2261,6 +2280,7 @@ async fn tool_suggest_description_lists_discoverable_tools() {
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::Plugins);
features.enable(Feature::ToolSearch);
features.enable(Feature::ToolSuggest);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
@@ -185,6 +185,11 @@ impl Respond for CodexAppsJsonRpcResponder {
{
"name": "calendar_create_event",
"description": "Create a calendar event.",
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"openWorldHint": false
},
"inputSchema": {
"type": "object",
"properties": {
@@ -209,6 +214,9 @@ impl Respond for CodexAppsJsonRpcResponder {
{
"name": "calendar_list_events",
"description": "List calendar events.",
"annotations": {
"readOnlyHint": true
},
"inputSchema": {
"type": "object",
"properties": {
@@ -241,6 +249,9 @@ impl Respond for CodexAppsJsonRpcResponder {
tools.push(json!({
"name": format!("calendar_timezone_option_{index}"),
"description": format!("Read timezone option {index}."),
"annotations": {
"readOnlyHint": true
},
"inputSchema": {
"type": "object",
"properties": {
+48 -1
View File
@@ -86,7 +86,7 @@ fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec<Va
.unwrap_or_default()
}
fn configure_apps(config: &mut Config, apps_base_url: &str) {
fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) {
config
.features
.enable(Feature::Apps)
@@ -105,6 +105,14 @@ fn configure_apps(config: &mut Config, apps_base_url: &str) {
config.model_catalog = Some(model_catalog);
}
fn configure_apps(config: &mut Config, apps_base_url: &str) {
configure_apps_without_tool_search(config, apps_base_url);
config
.features
.enable(Feature::ToolSearch)
.expect("test config should allow feature update");
}
fn configured_builder(apps_base_url: String) -> TestCodexBuilder {
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
@@ -169,6 +177,45 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount_searchable(&server).await?;
let mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
)
.await;
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
configure_apps_without_tool_search(config, apps_server.chatgpt_base_url.as_str())
});
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"list tools",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let body = mock.single_request().body_json();
let tools = tool_names(&body);
assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME));
assert!(tools.iter().any(|name| name == CALENDAR_CREATE_TOOL));
assert!(tools.iter().any(|name| name == CALENDAR_LIST_TOOL));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> {
skip_if_no_network!(Ok(()));
+15 -11
View File
@@ -142,6 +142,8 @@ pub enum Feature {
SpawnCsv,
/// Enable apps.
Apps,
/// Enable the tool_search tool for apps.
ToolSearch,
/// Enable discoverable tool suggestions for apps.
ToolSuggest,
/// Enable plugins.
@@ -714,24 +716,26 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::Apps,
key: "apps",
stage: Stage::Experimental {
name: "Apps",
menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.",
announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!",
},
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ToolSearch,
key: "tool_search",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ToolSuggest,
key: "tool_suggest",
stage: Stage::UnderDevelopment,
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::Plugins,
key: "plugins",
stage: Stage::UnderDevelopment,
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ImageGeneration,
@@ -782,8 +786,8 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::ToolCallMcpElicitation,
key: "tool_call_mcp_elicitation",
stage: Stage::UnderDevelopment,
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::Personality,
+15 -3
View File
@@ -115,9 +115,15 @@ fn request_permissions_tool_is_under_development() {
}
#[test]
fn tool_suggest_is_under_development() {
assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::ToolSuggest.default_enabled(), false);
fn tool_suggest_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable);
assert_eq!(Feature::ToolSuggest.default_enabled(), true);
}
#[test]
fn tool_search_is_under_development_and_disabled_by_default() {
assert_eq!(Feature::ToolSearch.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::ToolSearch.default_enabled(), false);
}
#[test]
@@ -138,6 +144,12 @@ fn image_generation_is_under_development() {
assert_eq!(Feature::ImageGeneration.default_enabled(), false);
}
#[test]
fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable);
assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true);
}
#[test]
fn image_detail_original_feature_is_under_development() {
assert_eq!(
+1
View File
@@ -1,5 +1,6 @@
pub const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call";
pub const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms";
pub const TOOL_CALL_UNIFIED_EXEC_METRIC: &str = "codex.tool.unified_exec";
pub const API_CALL_COUNT_METRIC: &str = "codex.api_request";
pub const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms";
pub const SSE_EVENT_COUNT_METRIC: &str = "codex.sse_event";
@@ -22,6 +22,7 @@ use rmcp::model::ResourceTemplate;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
use rmcp::model::Tool;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde_json::json;
use tokio::task;
@@ -85,11 +86,13 @@ impl TestToolServer {
}))
.expect("echo tool schema should deserialize");
Tool::new(
let mut tool = Tool::new(
Cow::Borrowed(name),
Cow::Borrowed(description),
Arc::new(schema),
)
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
tool
}
fn image_tool() -> Tool {
@@ -101,11 +104,13 @@ impl TestToolServer {
}))
.expect("image tool schema should deserialize");
Tool::new(
let mut tool = Tool::new(
Cow::Borrowed("image"),
Cow::Borrowed("Return a single image content block."),
Arc::new(schema),
)
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
tool
}
/// Tool intended for manual testing of Codex TUI rendering for MCP image tool results.
@@ -154,13 +159,15 @@ impl TestToolServer {
}))
.expect("image_scenario tool schema should deserialize");
Tool::new(
let mut tool = Tool::new(
Cow::Borrowed("image_scenario"),
Cow::Borrowed(
"Return content blocks for manual testing of MCP image rendering scenarios.",
),
Arc::new(schema),
)
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
tool
}
fn memo_resource() -> Resource {
@@ -38,6 +38,7 @@ use rmcp::model::ResourceTemplate;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
use rmcp::model::Tool;
use rmcp::model::ToolAnnotations;
use rmcp::transport::StreamableHttpServerConfig;
use rmcp::transport::StreamableHttpService;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
@@ -84,11 +85,13 @@ impl TestToolServer {
}))
.expect("echo tool schema should deserialize");
Tool::new(
let mut tool = Tool::new(
Cow::Borrowed("echo"),
Cow::Borrowed("Echo back the provided message and include environment data."),
Arc::new(schema),
)
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
tool
}
fn memo_resource() -> Resource {
@@ -26,6 +26,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-nam
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --with-marketplace
```
For a home-local plugin, treat `<home>` as the root and use:
```bash
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
--path ~/plugins \
--marketplace-path ~/.agents/plugins/marketplace.json \
--with-marketplace
```
4. Generate/adjust optional companion folders as needed:
```bash
@@ -37,6 +46,7 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin -
## What this skill creates
- If the user has not made the plugin location explicit, ask whether they want a repo-local plugin or a home-local plugin before generating marketplace entries.
- Creates plugin root at `/<parent-plugin-directory>/<plugin-name>/`.
- Always creates `/<parent-plugin-directory>/<plugin-name>/.codex-plugin/plugin.json`.
- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section.
@@ -58,6 +68,8 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin -
## Marketplace workflow
- `marketplace.json` always lives at `<repo-root>/.agents/plugins/marketplace.json`.
- For a home-local plugin, use the same convention with `<home>` as the root:
`~/.agents/plugins/marketplace.json` plus `./plugins/<plugin-name>`.
- Marketplace root metadata supports top-level `name` plus optional `interface.displayName`.
- Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list.
- `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries.
@@ -115,8 +115,10 @@
"source": "local",
"path": "./plugins/linear"
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
@@ -142,7 +144,9 @@
- `source` (`string`): Use `local` for this repo workflow.
- `path` (`string`): Relative plugin path based on the marketplace root.
- Repo plugin: `./plugins/<plugin-name>`
- Local plugin in `~/.agents/plugins/marketplace.json`: `./.codex/plugins/<plugin-name>`
- Local plugin in `~/.agents/plugins/marketplace.json`: `./plugins/<plugin-name>`
- The same relative path convention is used for both repo-rooted and home-rooted marketplaces.
- Example: with `~/.agents/plugins/marketplace.json`, `./plugins/<plugin-name>` resolves to `~/plugins/<plugin-name>`.
- `policy` (`object`): Marketplace policy block. Always include it.
- `installation` (`string`): Availability policy.
- Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT`
@@ -191,7 +191,10 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--path",
default=str(DEFAULT_PLUGIN_PARENT),
help="Parent directory for plugin creation (defaults to <cwd>/plugins)",
help=(
"Parent directory for plugin creation (defaults to <cwd>/plugins). "
"When using a home-rooted marketplace, use <home>/plugins."
),
)
parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory")
parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory")
@@ -202,12 +205,19 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--with-marketplace",
action="store_true",
help="Create or update <cwd>/.agents/plugins/marketplace.json",
help=(
"Create or update <cwd>/.agents/plugins/marketplace.json. "
"Marketplace entries always point to ./plugins/<plugin-name> relative to the "
"marketplace root."
),
)
parser.add_argument(
"--marketplace-path",
default=str(DEFAULT_MARKETPLACE_PATH),
help="Path to marketplace.json (defaults to <cwd>/.agents/plugins/marketplace.json)",
help=(
"Path to marketplace.json (defaults to <cwd>/.agents/plugins/marketplace.json). "
"For a home-rooted marketplace, use <home>/.agents/plugins/marketplace.json."
),
)
parser.add_argument(
"--install-policy",
+1 -13
View File
@@ -420,13 +420,6 @@ impl ChatWidget {
} else {
"Not installed yet."
};
let description = app
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
@@ -437,12 +430,7 @@ impl ChatWidget {
));
header.push(Line::from(status_label.dim()));
let mut items = vec![SelectionItem {
name: app.name.clone(),
description,
is_disabled: true,
..Default::default()
}];
let mut items = Vec::new();
if let Some(install_url) = app.install_url.clone() {
let install_label = if is_installed {
+48 -2
View File
@@ -621,6 +621,10 @@ impl ThreadEventStore {
fn active_turn_id(&self) -> Option<&str> {
self.active_turn_id.as_deref()
}
fn clear_active_turn_id(&mut self) {
self.active_turn_id = None;
}
}
#[derive(Debug)]
@@ -986,6 +990,13 @@ fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option<App
.then_some(turn_error)
}
fn active_turn_missing_steer_error(error: &TypedRequestError) -> bool {
let TypedRequestError::Server { source, .. } = error else {
return false;
};
source.message == "no active turn to steer"
}
impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
@@ -2021,23 +2032,32 @@ impl App {
collaboration_mode,
personality,
} => {
let mut should_start_turn = true;
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
match app_server
.turn_steer(thread_id, turn_id, items.to_vec())
.await
{
Ok(_) => {}
Ok(_) => return Ok(true),
Err(error) => {
if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) {
if !self.chat_widget.enqueue_rejected_steer() {
self.chat_widget.add_error_message(turn_error.message);
}
return Ok(true);
} else if active_turn_missing_steer_error(&error) {
if let Some(channel) = self.thread_event_channels.get(&thread_id) {
let mut store = channel.store.lock().await;
store.clear_active_turn_id();
}
should_start_turn = true;
} else {
return Err(error.into());
}
}
}
} else {
}
if should_start_turn {
app_server
.turn_start(
thread_id,
@@ -8043,6 +8063,17 @@ guardian_approval = true
assert_eq!(refreshed_store.active_turn_id(), Some("turn-2"));
}
#[test]
fn thread_event_store_clear_active_turn_id_resets_cached_turn() {
let mut store = ThreadEventStore::new(8);
let thread_id = ThreadId::new();
store.push_notification(turn_started_notification(thread_id, "turn-1"));
store.clear_active_turn_id();
assert_eq!(store.active_turn_id(), None);
}
#[test]
fn thread_event_store_rebase_preserves_resolved_request_state() {
let thread_id = ThreadId::new();
@@ -8273,6 +8304,21 @@ guardian_approval = true
);
}
#[test]
fn active_turn_missing_steer_error_detects_stale_turn_race() {
let error = TypedRequestError::Server {
method: "turn/steer".to_string(),
source: JSONRPCErrorError {
code: -32602,
message: "no active turn to steer".to_string(),
data: None,
},
};
assert!(active_turn_missing_steer_error(&error));
assert_eq!(active_turn_not_steerable_turn_error(&error), None);
}
#[test]
fn select_model_availability_nux_uses_existing_model_order_as_priority() {
let mut presets = all_model_presets();
@@ -420,13 +420,6 @@ impl ChatWidget {
} else {
"Not installed yet."
};
let description = app
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let mut header = ColumnRenderable::new();
header.push(Line::from("Plugins".bold()));
header.push(Line::from(
@@ -437,12 +430,7 @@ impl ChatWidget {
));
header.push(Line::from(status_label.dim()));
let mut items = vec![SelectionItem {
name: app.name.clone(),
description,
is_disabled: true,
..Default::default()
}];
let mut items = Vec::new();
if let Some(install_url) = app.install_url.clone() {
let install_label = if is_installed {
+33 -1
View File
@@ -144,6 +144,8 @@ pub use token::get_current_token_for_restriction;
#[cfg(target_os = "windows")]
pub use windows_impl::run_windows_sandbox_capture;
#[cfg(target_os = "windows")]
pub use windows_impl::run_windows_sandbox_capture_with_extra_deny_write_paths;
#[cfg(target_os = "windows")]
pub use windows_impl::run_windows_sandbox_legacy_preflight;
#[cfg(target_os = "windows")]
pub use windows_impl::CaptureResult;
@@ -259,6 +261,30 @@ mod windows_impl {
#[allow(clippy::too_many_arguments)]
pub fn run_windows_sandbox_capture(
policy_json_or_preset: &str,
sandbox_policy_cwd: &Path,
codex_home: &Path,
command: Vec<String>,
cwd: &Path,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
use_private_desktop: bool,
) -> Result<CaptureResult> {
run_windows_sandbox_capture_with_extra_deny_write_paths(
policy_json_or_preset,
sandbox_policy_cwd,
codex_home,
command,
cwd,
env_map,
timeout_ms,
&[],
use_private_desktop,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_windows_sandbox_capture_with_extra_deny_write_paths(
policy_json_or_preset: &str,
sandbox_policy_cwd: &Path,
codex_home: &Path,
@@ -266,6 +292,7 @@ mod windows_impl {
cwd: &Path,
mut env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
additional_deny_write_paths: &[PathBuf],
use_private_desktop: bool,
) -> Result<CaptureResult> {
let policy = parse_policy(policy_json_or_preset)?;
@@ -335,8 +362,13 @@ mod windows_impl {
}
let persist_aces = is_workspace_write;
let AllowDenyPaths { allow, deny } =
let AllowDenyPaths { allow, mut deny } =
compute_allow_paths(&policy, sandbox_policy_cwd, &current_dir, &env_map);
for path in additional_deny_write_paths {
if path.exists() {
deny.insert(path.clone());
}
}
let canonical_cwd = canonicalize_path(&current_dir);
let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new();
unsafe {
@@ -505,7 +505,7 @@ def test_real_examples_run_and_assert(
out = result.stdout
if folder == "01_quickstart_constructor":
assert "Status:" in out and "Text:" in out
assert "Server:" in out and "Items:" in out and "Text:" in out
assert "Server: unknown" not in out
elif folder == "02_turn_run":
assert "thread_id:" in out and "turn_id:" in out and "status:" in out