Merge branch 'skills/crate' of https://github.com/openai/codex into skills/crate

This commit is contained in:
Ahmed Ibrahim
2026-03-25 01:59:48 -07:00
42 changed files with 1582 additions and 307 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;
@@ -286,6 +287,7 @@ use codex_state::log_db::LogDbLayer;
use codex_utils_absolute_path::AbsolutePathBuf;
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;
@@ -374,7 +376,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>>>,
@@ -425,7 +428,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>,
@@ -489,6 +493,7 @@ impl CodexMessageProcessor {
arg0_paths,
config,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
feedback,
log_db,
@@ -500,6 +505,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())),
@@ -520,7 +526,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()
@@ -530,6 +536,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)
@@ -542,6 +549,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(
@@ -887,7 +908,8 @@ impl CodexMessageProcessor {
}
ClientRequest::ConfigRead { .. }
| ClientRequest::ConfigValueWrite { .. }
| ClientRequest::ConfigBatchWrite { .. } => {
| ClientRequest::ConfigBatchWrite { .. }
| ClientRequest::ExperimentalFeatureEnablementSet { .. } => {
warn!("Config request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::FsReadFile { .. }
@@ -1088,7 +1110,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(
@@ -1276,11 +1298,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 {})
@@ -1895,8 +1915,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(),
@@ -1906,10 +1926,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,
@@ -1982,6 +2004,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>>,
@@ -1998,6 +2021,7 @@ impl CodexMessageProcessor {
typesafe_overrides,
&cloud_requirements,
&listener_task_context.codex_home,
&runtime_feature_enablement,
)
.await
{
@@ -3500,13 +3524,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
{
@@ -4042,13 +4069,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
{
@@ -7277,12 +7307,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;
@@ -7297,6 +7328,7 @@ impl CodexMessageProcessor {
Some(command_cwd.clone()),
&cloud_requirements,
&config.codex_home,
&runtime_feature_enablement,
)
.await;
let setup_result = match derived_config {
@@ -7926,6 +7958,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()
@@ -7938,13 +7971,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(
@@ -7954,6 +7989,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()
@@ -7966,14 +8002,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| {
@@ -267,6 +453,7 @@ mod tests {
use codex_core::AuthManager;
use codex_core::CodexAuth;
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;
@@ -394,6 +581,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");
@@ -409,7 +656,8 @@ mod tests {
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
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;
@@ -229,6 +231,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(),
@@ -237,6 +241,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,
@@ -249,6 +254,7 @@ impl MessageProcessor {
let config_api = ConfigApi::new(
config.codex_home.clone(),
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
thread_manager,
@@ -703,6 +709,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: _,
@@ -851,7 +867,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"
},
-9
View File
@@ -2220,15 +2220,6 @@ impl Session {
self.persist_rollout_items(&rollout_items).await;
}
// Append the current session's initial context after the reconstructed history.
let initial_context = self.build_initial_context(&turn_context).await;
self.record_conversation_items(&turn_context, &initial_context)
.await;
{
let mut state = self.state.lock().await;
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
}
// Forked threads should remain file-backed immediately after startup.
self.ensure_rollout_materialized().await;
+11 -9
View File
@@ -1115,18 +1115,12 @@ async fn recompute_token_usage_updates_model_context_window() {
#[tokio::test]
async fn record_initial_history_reconstructs_forked_transcript() {
let (session, turn_context) = make_session_and_context().await;
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
session
.record_initial_history(InitialHistory::Forked(rollout_items))
.await;
let reconstruction_turn = session.new_default_turn().await;
expected.extend(
session
.build_initial_context(reconstruction_turn.as_ref())
.await,
);
let history = session.state.lock().await.clone_history();
assert_eq!(expected, history.raw_items());
}
@@ -1244,7 +1238,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
let request = first_forked_request.single_request();
let snapshot = context_snapshot::format_labeled_requests_snapshot(
"First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.",
"First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.",
&[("First Forked Turn Request", &request)],
&ContextSnapshotOptions::default()
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
@@ -1309,7 +1303,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
text_elements: Vec::new(),
},
)),
RolloutItem::TurnContext(previous_context_item),
RolloutItem::TurnContext(previous_context_item.clone()),
RolloutItem::EventMsg(EventMsg::TurnComplete(
codex_protocol::protocol::TurnCompleteEvent {
turn_id,
@@ -1322,6 +1316,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
.record_initial_history(InitialHistory::Forked(rollout_items))
.await;
let history = session.clone_history().await;
assert_eq!(
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
@@ -1329,6 +1324,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
realtime_active: Some(turn_context.realtime_active),
})
);
assert_eq!(history.raw_items(), &[]);
assert_eq!(
serde_json::to_value(session.reference_context_item().await)
.expect("serialize fork reference context item"),
serde_json::to_value(Some(previous_context_item))
.expect("serialize expected reference context item")
);
}
#[tokio::test]
@@ -193,6 +193,10 @@ pub(crate) fn build_settings_update_items(
exec_policy: &Policy,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
// TODO(ccunningham): build_settings_update_items still does not cover every
// model-visible item emitted by build_initial_context. Persist the remaining
// inputs or add explicit replay events so fork/resume can diff everything
// deterministically.
let contextual_user_message = build_environment_update_item(previous, next, shell);
let developer_update_sections = [
// Keep model-switch instructions first so model-specific guidance is read before
+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,
@@ -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,17 +1,15 @@
---
source: core/src/codex_tests.rs
assertion_line: 1282
assertion_line: 1254
expression: snapshot
---
Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.
Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.
## First Forked Turn Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:fork seed
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
05:message/developer[2]:
03:message/developer[2]:
[01] <PERMISSIONS_INSTRUCTIONS>
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
06:message/user:after fork
04:message/user:after fork
+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,
};
@@ -138,6 +138,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)?;
@@ -918,6 +919,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
@@ -1858,6 +1858,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() {
let model_info = search_capable_model_info();
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,
@@ -1976,7 +1977,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() {
}
#[test]
fn search_tool_requires_model_capability_only() {
fn search_tool_requires_model_capability_and_feature_flag() {
let model_info = search_capable_model_info();
let app_tools = Some(HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
@@ -2012,6 +2013,22 @@ 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,
@@ -2030,8 +2047,8 @@ fn search_tool_requires_model_capability_only() {
fn tool_suggest_is_not_registered_without_feature_flag() {
let model_info = search_capable_model_info();
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,
@@ -2074,12 +2091,11 @@ 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,
@@ -2113,6 +2129,7 @@ fn search_tool_description_handles_no_enabled_apps() {
let model_info = search_capable_model_info();
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,
@@ -2139,6 +2156,7 @@ fn search_tool_description_falls_back_to_connector_name_without_description() {
let model_info = search_capable_model_info();
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,
@@ -2187,6 +2205,7 @@ fn search_tool_registers_namespaced_app_tool_aliases() {
let model_info = search_capable_model_info();
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,
@@ -2253,6 +2272,7 @@ 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 {
+2 -4
View File
@@ -125,9 +125,8 @@ async fn fork_thread_twice_drops_to_first_message() {
// GetHistory on fork1 flushed; the file is ready.
let fork1_items = read_items(&fork1_path);
assert!(fork1_items.len() > expected_after_first.len());
pretty_assertions::assert_eq!(
serde_json::to_value(&fork1_items[..expected_after_first.len()]).unwrap(),
serde_json::to_value(&fork1_items).unwrap(),
serde_json::to_value(&expected_after_first).unwrap()
);
@@ -156,9 +155,8 @@ async fn fork_thread_twice_drops_to_first_message() {
.unwrap_or(0);
let expected_after_second: Vec<RolloutItem> = fork1_items[..cut_last_on_fork1].to_vec();
let fork2_items = read_items(&fork2_path);
assert!(fork2_items.len() > expected_after_second.len());
pretty_assertions::assert_eq!(
serde_json::to_value(&fork2_items[..expected_after_second.len()]).unwrap(),
serde_json::to_value(&fork2_items).unwrap(),
serde_json::to_value(&expected_after_second).unwrap()
);
}
@@ -443,14 +443,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
let body4 = req4.single_request().body_json();
let input4 = body4["input"].as_array().expect("input array");
let permissions_fork = permissions_texts(input4);
assert_eq!(permissions_fork.len(), permissions_base.len() + 2);
assert_eq!(permissions_fork.len(), permissions_base.len() + 1);
assert_eq!(
&permissions_fork[..permissions_base.len()],
permissions_base.as_slice()
);
let new_permissions = &permissions_fork[permissions_base.len()..];
assert_eq!(new_permissions.len(), 2);
assert_eq!(new_permissions[0], new_permissions[1]);
assert_eq!(new_permissions.len(), 1);
assert_eq!(permissions_fork, permissions_resume);
assert!(!permissions_base.contains(&new_permissions[0]));
Ok(())
+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 -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