mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
Merge remote-tracking branch 'origin/main' into pakrym/pr-15695-merge-main
# Conflicts: # codex-rs/core/src/tools/spec_tests.rs
This commit is contained in:
@@ -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": {
|
||||
|
||||
+17
@@ -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"
|
||||
}
|
||||
+17
@@ -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
+12
@@ -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 }, };
|
||||
+9
@@ -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/")]
|
||||
|
||||
@@ -164,6 +164,7 @@ Example with notification opt-out:
|
||||
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
|
||||
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::bespoke_event_handling::apply_bespoke_event_handling;
|
||||
use crate::command_exec::CommandExecManager;
|
||||
use crate::command_exec::StartCommandExecParams;
|
||||
use crate::config_api::apply_runtime_feature_enablement;
|
||||
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
|
||||
use crate::error_code::INTERNAL_ERROR_CODE;
|
||||
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
|
||||
@@ -283,6 +284,7 @@ use codex_state::ThreadMetadataBuilder;
|
||||
use codex_state::log_db::LogDbLayer;
|
||||
use codex_utils_json_to_toml::json_to_toml;
|
||||
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
@@ -371,7 +373,8 @@ pub(crate) struct CodexMessageProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
config: Arc<Config>,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
|
||||
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
|
||||
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
|
||||
active_login: Arc<Mutex<Option<ActiveLogin>>>,
|
||||
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
|
||||
@@ -422,7 +425,8 @@ pub(crate) struct CodexMessageProcessorArgs {
|
||||
pub(crate) outgoing: Arc<OutgoingMessageSender>,
|
||||
pub(crate) arg0_paths: Arg0DispatchPaths,
|
||||
pub(crate) config: Arc<Config>,
|
||||
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
|
||||
pub(crate) cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
|
||||
pub(crate) runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
|
||||
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
|
||||
pub(crate) feedback: CodexFeedback,
|
||||
pub(crate) log_db: Option<LogDbLayer>,
|
||||
@@ -486,6 +490,7 @@ impl CodexMessageProcessor {
|
||||
arg0_paths,
|
||||
config,
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
cloud_requirements,
|
||||
feedback,
|
||||
log_db,
|
||||
@@ -497,6 +502,7 @@ impl CodexMessageProcessor {
|
||||
arg0_paths,
|
||||
config,
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
cloud_requirements,
|
||||
active_login: Arc::new(Mutex::new(None)),
|
||||
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -517,7 +523,7 @@ impl CodexMessageProcessor {
|
||||
) -> Result<Config, JSONRPCErrorError> {
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.cli_overrides(self.cli_overrides.clone())
|
||||
.cli_overrides(self.current_cli_overrides())
|
||||
.fallback_cwd(fallback_cwd)
|
||||
.cloud_requirements(cloud_requirements)
|
||||
.build()
|
||||
@@ -527,6 +533,7 @@ impl CodexMessageProcessor {
|
||||
message: format!("failed to reload config: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
|
||||
config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
|
||||
config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone();
|
||||
Ok(config)
|
||||
@@ -539,6 +546,20 @@ impl CodexMessageProcessor {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
|
||||
self.cli_overrides
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
|
||||
self.runtime_feature_enablement
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// If a client sends `developer_instructions: null` during a mode switch,
|
||||
/// use the built-in instructions for that mode.
|
||||
fn normalize_turn_start_collaboration_mode(
|
||||
@@ -884,7 +905,8 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
ClientRequest::ConfigRead { .. }
|
||||
| ClientRequest::ConfigValueWrite { .. }
|
||||
| ClientRequest::ConfigBatchWrite { .. } => {
|
||||
| ClientRequest::ConfigBatchWrite { .. }
|
||||
| ClientRequest::ExperimentalFeatureEnablementSet { .. } => {
|
||||
warn!("Config request reached CodexMessageProcessor unexpectedly");
|
||||
}
|
||||
ClientRequest::FsReadFile { .. }
|
||||
@@ -1085,7 +1107,7 @@ impl CodexMessageProcessor {
|
||||
let cloud_requirements = self.cloud_requirements.clone();
|
||||
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
let cli_overrides = self.cli_overrides.clone();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let auth_url = server.auth_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = match tokio::time::timeout(
|
||||
@@ -1273,11 +1295,9 @@ impl CodexMessageProcessor {
|
||||
self.config.chatgpt_base_url.clone(),
|
||||
self.config.codex_home.clone(),
|
||||
);
|
||||
sync_default_client_residency_requirement(
|
||||
&self.cli_overrides,
|
||||
self.cloud_requirements.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref())
|
||||
.await;
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {})
|
||||
@@ -1892,8 +1912,8 @@ impl CodexMessageProcessor {
|
||||
personality,
|
||||
);
|
||||
typesafe_overrides.ephemeral = ephemeral;
|
||||
let cli_overrides = self.cli_overrides.clone();
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let listener_task_context = ListenerTaskContext {
|
||||
thread_manager: Arc::clone(&self.thread_manager),
|
||||
thread_state_manager: self.thread_state_manager.clone(),
|
||||
@@ -1903,10 +1923,12 @@ impl CodexMessageProcessor {
|
||||
codex_home: self.config.codex_home.clone(),
|
||||
};
|
||||
let request_trace = request_context.request_trace();
|
||||
let runtime_feature_enablement = self.current_runtime_feature_enablement();
|
||||
let thread_start_task = async move {
|
||||
Self::thread_start_task(
|
||||
listener_task_context,
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
cloud_requirements,
|
||||
request_id,
|
||||
config,
|
||||
@@ -1979,6 +2001,7 @@ impl CodexMessageProcessor {
|
||||
async fn thread_start_task(
|
||||
listener_task_context: ListenerTaskContext,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
runtime_feature_enablement: BTreeMap<String, bool>,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
request_id: ConnectionRequestId,
|
||||
config_overrides: Option<HashMap<String, serde_json::Value>>,
|
||||
@@ -1995,6 +2018,7 @@ impl CodexMessageProcessor {
|
||||
typesafe_overrides,
|
||||
&cloud_requirements,
|
||||
&listener_task_context.codex_home,
|
||||
&runtime_feature_enablement,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3497,13 +3521,16 @@ impl CodexMessageProcessor {
|
||||
|
||||
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let runtime_feature_enablement = self.current_runtime_feature_enablement();
|
||||
let config = match derive_config_for_cwd(
|
||||
&self.cli_overrides,
|
||||
&cli_overrides,
|
||||
request_overrides,
|
||||
typesafe_overrides,
|
||||
history_cwd,
|
||||
&cloud_requirements,
|
||||
&self.config.codex_home,
|
||||
&runtime_feature_enablement,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4039,13 +4066,16 @@ impl CodexMessageProcessor {
|
||||
typesafe_overrides.ephemeral = ephemeral.then_some(true);
|
||||
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let runtime_feature_enablement = self.current_runtime_feature_enablement();
|
||||
let config = match derive_config_for_cwd(
|
||||
&self.cli_overrides,
|
||||
&cli_overrides,
|
||||
request_overrides,
|
||||
typesafe_overrides,
|
||||
history_cwd,
|
||||
&cloud_requirements,
|
||||
&self.config.codex_home,
|
||||
&runtime_feature_enablement,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -7224,12 +7254,13 @@ impl CodexMessageProcessor {
|
||||
WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated,
|
||||
};
|
||||
let config = Arc::clone(&self.config);
|
||||
let cli_overrides = self.cli_overrides.clone();
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let command_cwd = params
|
||||
.cwd
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| config.cwd.clone());
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let runtime_feature_enablement = self.current_runtime_feature_enablement();
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let connection_id = request_id.connection_id;
|
||||
|
||||
@@ -7244,6 +7275,7 @@ impl CodexMessageProcessor {
|
||||
Some(command_cwd.clone()),
|
||||
&cloud_requirements,
|
||||
&config.codex_home,
|
||||
&runtime_feature_enablement,
|
||||
)
|
||||
.await;
|
||||
let setup_result = match derived_config {
|
||||
@@ -7873,6 +7905,7 @@ async fn derive_config_from_params(
|
||||
typesafe_overrides: ConfigOverrides,
|
||||
cloud_requirements: &CloudRequirementsLoader,
|
||||
codex_home: &Path,
|
||||
runtime_feature_enablement: &BTreeMap<String, bool>,
|
||||
) -> std::io::Result<Config> {
|
||||
let merged_cli_overrides = cli_overrides
|
||||
.iter()
|
||||
@@ -7885,13 +7918,15 @@ async fn derive_config_from_params(
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
codex_core::config::ConfigBuilder::default()
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.cli_overrides(merged_cli_overrides)
|
||||
.harness_overrides(typesafe_overrides)
|
||||
.cloud_requirements(cloud_requirements.clone())
|
||||
.build()
|
||||
.await
|
||||
.await?;
|
||||
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn derive_config_for_cwd(
|
||||
@@ -7901,6 +7936,7 @@ async fn derive_config_for_cwd(
|
||||
cwd: Option<PathBuf>,
|
||||
cloud_requirements: &CloudRequirementsLoader,
|
||||
codex_home: &Path,
|
||||
runtime_feature_enablement: &BTreeMap<String, bool>,
|
||||
) -> std::io::Result<Config> {
|
||||
let merged_cli_overrides = cli_overrides
|
||||
.iter()
|
||||
@@ -7913,14 +7949,16 @@ async fn derive_config_for_cwd(
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
codex_core::config::ConfigBuilder::default()
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.cli_overrides(merged_cli_overrides)
|
||||
.harness_overrides(typesafe_overrides)
|
||||
.fallback_cwd(cwd)
|
||||
.cloud_requirements(cloud_requirements.clone())
|
||||
.build()
|
||||
.await
|
||||
.await?;
|
||||
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn read_history_cwd_from_state_db(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,11 +9,14 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::ConfigWriteErrorCode;
|
||||
use codex_app_server_protocol::ConfigWriteResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::NetworkRequirements;
|
||||
use codex_app_server_protocol::SandboxMode;
|
||||
use codex_core::AnalyticsEventsClient;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigService;
|
||||
use codex_core::config::ConfigServiceError;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
@@ -24,15 +27,27 @@ use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirem
|
||||
use codex_core::plugins::PluginId;
|
||||
use codex_core::plugins::collect_plugin_enabled_candidates;
|
||||
use codex_core::plugins::installed_plugin_telemetry_metadata;
|
||||
use codex_features::canonical_feature_for_key;
|
||||
use codex_features::feature_for_key;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::protocol::Op;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::warn;
|
||||
|
||||
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
||||
"apps",
|
||||
"plugins",
|
||||
"tool_search",
|
||||
"tool_suggest",
|
||||
"tool_call_mcp_elicitation",
|
||||
];
|
||||
|
||||
#[async_trait]
|
||||
pub(crate) trait UserConfigReloader: Send + Sync {
|
||||
async fn reload_user_config(&self);
|
||||
@@ -56,7 +71,8 @@ impl UserConfigReloader for ThreadManager {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ConfigApi {
|
||||
codex_home: PathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
|
||||
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
|
||||
user_config_reloader: Arc<dyn UserConfigReloader>,
|
||||
@@ -66,7 +82,8 @@ pub(crate) struct ConfigApi {
|
||||
impl ConfigApi {
|
||||
pub(crate) fn new(
|
||||
codex_home: PathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
|
||||
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
|
||||
user_config_reloader: Arc<dyn UserConfigReloader>,
|
||||
@@ -75,6 +92,7 @@ impl ConfigApi {
|
||||
Self {
|
||||
codex_home,
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
user_config_reloader,
|
||||
@@ -83,24 +101,87 @@ impl ConfigApi {
|
||||
}
|
||||
|
||||
fn config_service(&self) -> ConfigService {
|
||||
let cloud_requirements = self
|
||||
.cloud_requirements
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default();
|
||||
ConfigService::new(
|
||||
self.codex_home.clone(),
|
||||
self.cli_overrides.clone(),
|
||||
self.current_cli_overrides(),
|
||||
self.loader_overrides.clone(),
|
||||
cloud_requirements,
|
||||
self.current_cloud_requirements(),
|
||||
)
|
||||
}
|
||||
|
||||
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
|
||||
self.cli_overrides
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
|
||||
self.runtime_feature_enablement
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn current_cloud_requirements(&self) -> CloudRequirementsLoader {
|
||||
self.cloud_requirements
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn load_latest_config(
|
||||
&self,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
) -> Result<Config, JSONRPCErrorError> {
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(self.codex_home.clone())
|
||||
.cli_overrides(self.current_cli_overrides())
|
||||
.loader_overrides(self.loader_overrides.clone())
|
||||
.fallback_cwd(fallback_cwd)
|
||||
.cloud_requirements(self.current_cloud_requirements())
|
||||
.build()
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to resolve feature override precedence: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) async fn read(
|
||||
&self,
|
||||
params: ConfigReadParams,
|
||||
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
|
||||
self.config_service().read(params).await.map_err(map_error)
|
||||
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
|
||||
let mut response = self
|
||||
.config_service()
|
||||
.read(params)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
let config = self.load_latest_config(fallback_cwd).await?;
|
||||
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
|
||||
let Some(feature) = feature_for_key(feature_key) else {
|
||||
continue;
|
||||
};
|
||||
let features = response
|
||||
.config
|
||||
.additional
|
||||
.entry("features".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !features.is_object() {
|
||||
*features = json!({});
|
||||
}
|
||||
if let Some(features) = features.as_object_mut() {
|
||||
features.insert(
|
||||
(*feature_key).to_string(),
|
||||
json!(config.features.enabled(feature)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub(crate) async fn config_requirements_read(
|
||||
@@ -154,6 +235,68 @@ impl ConfigApi {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_experimental_feature_enablement(
|
||||
&self,
|
||||
params: ExperimentalFeatureEnablementSetParams,
|
||||
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
|
||||
let ExperimentalFeatureEnablementSetParams { enablement } = params;
|
||||
for key in enablement.keys() {
|
||||
if canonical_feature_for_key(key).is_some() {
|
||||
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!(
|
||||
"unsupported feature enablement `{key}`: currently supported features are {}",
|
||||
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
|
||||
),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
let message = if let Some(feature) = feature_for_key(key) {
|
||||
format!(
|
||||
"invalid feature enablement `{key}`: use canonical feature key `{}`",
|
||||
feature.key()
|
||||
)
|
||||
} else {
|
||||
format!("invalid feature enablement `{key}`")
|
||||
};
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message,
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
if enablement.is_empty() {
|
||||
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
|
||||
}
|
||||
|
||||
{
|
||||
let mut runtime_feature_enablement =
|
||||
self.runtime_feature_enablement
|
||||
.write()
|
||||
.map_err(|_| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: "failed to update feature enablement".to_string(),
|
||||
data: None,
|
||||
})?;
|
||||
runtime_feature_enablement.extend(
|
||||
enablement
|
||||
.iter()
|
||||
.map(|(name, enabled)| (name.clone(), *enabled)),
|
||||
);
|
||||
}
|
||||
|
||||
self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
self.user_config_reloader.reload_user_config().await;
|
||||
|
||||
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
|
||||
}
|
||||
|
||||
fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap<String, bool>) {
|
||||
for (plugin_id, enabled) in pending_changes {
|
||||
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
|
||||
@@ -170,6 +313,49 @@ impl ConfigApi {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn protected_feature_keys(
|
||||
config_layer_stack: &codex_core::config_loader::ConfigLayerStack,
|
||||
) -> BTreeSet<String> {
|
||||
let mut protected_features = config_layer_stack
|
||||
.effective_config()
|
||||
.get("features")
|
||||
.and_then(toml::Value::as_table)
|
||||
.map(|features| features.keys().cloned().collect::<BTreeSet<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(feature_requirements) = config_layer_stack
|
||||
.requirements_toml()
|
||||
.feature_requirements
|
||||
.as_ref()
|
||||
{
|
||||
protected_features.extend(feature_requirements.entries.keys().cloned());
|
||||
}
|
||||
|
||||
protected_features
|
||||
}
|
||||
|
||||
pub(crate) fn apply_runtime_feature_enablement(
|
||||
config: &mut Config,
|
||||
runtime_feature_enablement: &BTreeMap<String, bool>,
|
||||
) {
|
||||
let protected_features = protected_feature_keys(&config.config_layer_stack);
|
||||
for (name, enabled) in runtime_feature_enablement {
|
||||
if protected_features.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let Some(feature) = feature_for_key(name) else {
|
||||
continue;
|
||||
};
|
||||
if let Err(err) = config.features.set_enabled(feature, *enabled) {
|
||||
warn!(
|
||||
feature = name,
|
||||
error = %err,
|
||||
"failed to apply runtime feature enablement"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
|
||||
ConfigRequirements {
|
||||
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
|
||||
@@ -265,6 +451,7 @@ mod tests {
|
||||
use super::*;
|
||||
use codex_core::AnalyticsEventsClient;
|
||||
use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
@@ -392,6 +579,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\napps = false\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cli_overrides(vec![(
|
||||
"features.apps".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
apply_runtime_feature_enablement(
|
||||
&mut config,
|
||||
&BTreeMap::from([("apps".to_string(), false)]),
|
||||
);
|
||||
|
||||
assert!(config.features.enabled(Feature::Apps));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cli_overrides(vec![(
|
||||
"features.apps".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
feature_requirements: Some(
|
||||
codex_core::config_loader::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("apps".to_string(), false)]),
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
apply_runtime_feature_enablement(
|
||||
&mut config,
|
||||
&BTreeMap::from([("apps".to_string(), true)]),
|
||||
);
|
||||
|
||||
assert!(!config.features.enabled(Feature::Apps));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_write_reloads_user_config_when_requested() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
@@ -406,7 +653,8 @@ mod tests {
|
||||
);
|
||||
let config_api = ConfigApi::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
Vec::new(),
|
||||
Arc::new(RwLock::new(Vec::new())),
|
||||
Arc::new(RwLock::new(BTreeMap::new())),
|
||||
LoaderOverrides::default(),
|
||||
Arc::new(RwLock::new(CloudRequirementsLoader::default())),
|
||||
reloader.clone(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
@@ -30,6 +31,7 @@ use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::ExperimentalApi;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
||||
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
|
||||
use codex_app_server_protocol::ExternalAgentConfigImportParams;
|
||||
use codex_app_server_protocol::FsCopyParams;
|
||||
@@ -226,6 +228,8 @@ impl MessageProcessor {
|
||||
.plugins_manager()
|
||||
.set_analytics_events_client(analytics_events_client.clone());
|
||||
|
||||
let cli_overrides = Arc::new(RwLock::new(cli_overrides));
|
||||
let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new()));
|
||||
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
|
||||
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
|
||||
auth_manager: auth_manager.clone(),
|
||||
@@ -234,6 +238,7 @@ impl MessageProcessor {
|
||||
arg0_paths,
|
||||
config: Arc::clone(&config),
|
||||
cli_overrides: cli_overrides.clone(),
|
||||
runtime_feature_enablement: runtime_feature_enablement.clone(),
|
||||
cloud_requirements: cloud_requirements.clone(),
|
||||
feedback,
|
||||
log_db,
|
||||
@@ -246,6 +251,7 @@ impl MessageProcessor {
|
||||
let config_api = ConfigApi::new(
|
||||
config.codex_home.clone(),
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
thread_manager,
|
||||
@@ -700,6 +706,16 @@ impl MessageProcessor {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => {
|
||||
self.handle_experimental_feature_enablement_set(
|
||||
ConnectionRequestId {
|
||||
connection_id,
|
||||
request_id,
|
||||
},
|
||||
params,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ConfigRequirementsRead {
|
||||
request_id,
|
||||
params: _,
|
||||
@@ -848,7 +864,30 @@ impl MessageProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
params: ConfigBatchWriteParams,
|
||||
) {
|
||||
match self.config_api.batch_write(params).await {
|
||||
self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn handle_experimental_feature_enablement_set(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ExperimentalFeatureEnablementSetParams,
|
||||
) {
|
||||
self.handle_config_mutation_result(
|
||||
request_id,
|
||||
self.config_api
|
||||
.set_experimental_feature_enablement(params)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn handle_config_mutation_result<T: serde::Serialize>(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
result: std::result::Result<T, JSONRPCErrorError>,
|
||||
) {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
self.codex_message_processor.clear_plugin_related_caches();
|
||||
self.codex_message_processor
|
||||
|
||||
@@ -465,6 +465,16 @@ impl McpProcess {
|
||||
self.send_request("experimentalFeature/list", params).await
|
||||
}
|
||||
|
||||
/// Send an `experimentalFeature/enablement/set` JSON-RPC request.
|
||||
pub async fn send_experimental_feature_enablement_set_request(
|
||||
&mut self,
|
||||
params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("experimentalFeature/enablement/set", params)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send an `app/list` JSON-RPC request.
|
||||
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
|
||||
@@ -3,16 +3,24 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigReadResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeature;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListParams;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeatureStage;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -34,13 +42,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
|
||||
.send_experimental_feature_list_request(ExperimentalFeatureListParams::default())
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let actual = to_response::<ExperimentalFeatureListResponse>(response)?;
|
||||
let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?;
|
||||
let expected_data = FEATURES
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
@@ -82,3 +84,240 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
|
||||
assert_eq!(actual, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let project_cwd = codex_home.path().join("project");
|
||||
std::fs::create_dir_all(&project_cwd)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let actual =
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([("apps".to_string(), true)]),
|
||||
}
|
||||
);
|
||||
|
||||
for cwd in [None, Some(project_cwd.display().to_string())] {
|
||||
let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\napps = false\n",
|
||||
)?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let actual =
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([("apps".to_string(), true)]),
|
||||
}
|
||||
);
|
||||
|
||||
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
Some(&json!(false))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
let actual = set_experimental_feature_enablement(
|
||||
&mut mcp,
|
||||
BTreeMap::from([
|
||||
("plugins".to_string(), true),
|
||||
("tool_search".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([
|
||||
("plugins".to_string(), true),
|
||||
("tool_search".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
]),
|
||||
}
|
||||
);
|
||||
|
||||
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("plugins")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("tool_search")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("tool_suggest")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("tool_call_mcp_elicitation")),
|
||||
Some(&json!(false))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?;
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::new(),
|
||||
}
|
||||
);
|
||||
|
||||
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
||||
enablement: BTreeMap::from([("personality".to_string(), true)]),
|
||||
})
|
||||
.await?;
|
||||
let JSONRPCError { error, .. } = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.code, -32600);
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.contains("unsupported feature enablement `personality`"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_experimental_feature_enablement(
|
||||
mcp: &mut McpProcess,
|
||||
enablement: BTreeMap<String, bool>,
|
||||
) -> Result<ExperimentalFeatureEnablementSetResponse> {
|
||||
let request_id = mcp
|
||||
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
||||
enablement,
|
||||
})
|
||||
.await?;
|
||||
read_response(mcp, request_id).await
|
||||
}
|
||||
|
||||
async fn read_config(mcp: &mut McpProcess, cwd: Option<String>) -> Result<ConfigReadResponse> {
|
||||
let request_id = mcp
|
||||
.send_config_read_request(ConfigReadParams {
|
||||
include_layers: false,
|
||||
cwd,
|
||||
})
|
||||
.await?;
|
||||
read_response(mcp, request_id).await
|
||||
}
|
||||
|
||||
async fn read_response<T: DeserializeOwned>(mcp: &mut McpProcess, request_id: i64) -> Result<T> {
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response(response)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -485,6 +485,9 @@
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_search": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_suggest": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2099,6 +2102,9 @@
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_search": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_suggest": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
+210
-58
@@ -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
@@ -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,
|
||||
|
||||
@@ -495,7 +495,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
approval_mode: AppToolApproval,
|
||||
) -> Option<McpToolApprovalDecision> {
|
||||
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
|
||||
let approval_required = annotations.is_some_and(requires_mcp_tool_approval);
|
||||
let approval_required = requires_mcp_tool_approval(annotations);
|
||||
let mut monitor_reason = None;
|
||||
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve
|
||||
|| (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context));
|
||||
@@ -1299,12 +1299,23 @@ async fn persist_codex_app_tool_approval(
|
||||
.await
|
||||
}
|
||||
|
||||
fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool {
|
||||
if annotations.destructive_hint == Some(true) {
|
||||
fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
|
||||
let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint);
|
||||
if destructive_hint == Some(true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true)
|
||||
let read_only_hint = annotations
|
||||
.and_then(|annotations| annotations.read_only_hint)
|
||||
.unwrap_or(false);
|
||||
if read_only_hint {
|
||||
return false;
|
||||
}
|
||||
|
||||
destructive_hint.unwrap_or(true)
|
||||
|| annotations
|
||||
.and_then(|annotations| annotations.open_world_hint)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn notify_mcp_tool_call_skip(
|
||||
|
||||
@@ -64,19 +64,30 @@ fn prompt_options(
|
||||
#[test]
|
||||
fn approval_required_when_read_only_false_and_destructive() {
|
||||
let annotations = annotations(Some(false), Some(true), None);
|
||||
assert_eq!(requires_mcp_tool_approval(&annotations), true);
|
||||
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_read_only_false_and_open_world() {
|
||||
let annotations = annotations(Some(false), None, Some(true));
|
||||
assert_eq!(requires_mcp_tool_approval(&annotations), true);
|
||||
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_destructive_even_if_read_only_true() {
|
||||
let annotations = annotations(Some(true), Some(true), Some(true));
|
||||
assert_eq!(requires_mcp_tool_approval(&annotations), true);
|
||||
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_annotations_are_absent() {
|
||||
assert_eq!(requires_mcp_tool_approval(None), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_not_required_when_read_only_and_other_hints_are_absent() {
|
||||
let annotations = annotations(Some(true), None, None);
|
||||
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1067,6 +1078,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let invocation = McpInvocation {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool: "dangerous_tool".to_string(),
|
||||
arguments: Some(serde_json::json!({ "id": 1 })),
|
||||
};
|
||||
let metadata = McpToolApprovalMetadata {
|
||||
annotations: None,
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-3",
|
||||
&invocation,
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
|
||||
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
use wiremock::Mock;
|
||||
|
||||
@@ -44,6 +44,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
||||
write_file(
|
||||
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
|
||||
windows_restricted_token_filesystem_overlay: None,
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use crate::unified_exec::WriteStdinRequest;
|
||||
use async_trait::async_trait;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::metrics::names::TOOL_CALL_UNIFIED_EXEC_METRIC;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
@@ -260,6 +262,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
});
|
||||
}
|
||||
|
||||
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
|
||||
manager
|
||||
.exec_command(
|
||||
ExecCommandRequest {
|
||||
@@ -323,6 +326,14 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) {
|
||||
session_telemetry.counter(
|
||||
TOOL_CALL_UNIFIED_EXEC_METRIC,
|
||||
/*inc*/ 1,
|
||||
&[("tty", if tty { "true" } else { "false" })],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn get_command(
|
||||
args: &ExecCommandArgs,
|
||||
session_shell: Arc<Shell>,
|
||||
|
||||
@@ -137,6 +137,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay: _windows_restricted_token_filesystem_overlay,
|
||||
arg0,
|
||||
} = sandbox_exec_request;
|
||||
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
|
||||
@@ -909,6 +910,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
sandbox_policy: self.sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
|
||||
network_sandbox_policy: self.network_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay: None,
|
||||
arg0: self.arg0.clone(),
|
||||
},
|
||||
/*stdout_stream*/ None,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1867,6 +1867,7 @@ async fn search_tool_description_lists_each_codex_apps_connector_once() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -1985,7 +1986,7 @@ async fn search_tool_description_lists_each_codex_apps_connector_once() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_tool_requires_model_capability_only() {
|
||||
async fn search_tool_requires_model_capability_and_feature_flag() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let app_tools = Some(HashMap::from([(
|
||||
"mcp__codex_apps__calendar_create_event".to_string(),
|
||||
@@ -2021,6 +2022,22 @@ async fn search_tool_requires_model_capability_only() {
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
||||
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
||||
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2039,8 +2056,8 @@ async fn search_tool_requires_model_capability_only() {
|
||||
async fn tool_suggest_is_not_registered_without_feature_flag() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::Plugins);
|
||||
features.enable(Feature::ToolSearch);
|
||||
features.disable(Feature::ToolSuggest);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2083,12 +2100,11 @@ async fn tool_suggest_requires_apps_and_plugins_features() {
|
||||
|
||||
for disabled_feature in [Feature::Apps, Feature::Plugins] {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::ToolSearch);
|
||||
features.enable(Feature::ToolSuggest);
|
||||
for feature in [Feature::Apps, Feature::Plugins] {
|
||||
if feature != disabled_feature {
|
||||
features.enable(feature);
|
||||
}
|
||||
}
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::Plugins);
|
||||
features.disable(disabled_feature);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2121,6 +2137,7 @@ async fn search_tool_description_handles_no_enabled_apps() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2147,6 +2164,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2195,6 +2213,7 @@ async fn search_tool_registers_namespaced_app_tool_aliases() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2261,6 +2280,7 @@ async fn tool_suggest_description_lists_discoverable_tools() {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::Plugins);
|
||||
features.enable(Feature::ToolSearch);
|
||||
features.enable(Feature::ToolSuggest);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
|
||||
@@ -185,6 +185,11 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
{
|
||||
"name": "calendar_create_event",
|
||||
"description": "Create a calendar event.",
|
||||
"annotations": {
|
||||
"readOnlyHint": false,
|
||||
"destructiveHint": false,
|
||||
"openWorldHint": false
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -209,6 +214,9 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
{
|
||||
"name": "calendar_list_events",
|
||||
"description": "List calendar events.",
|
||||
"annotations": {
|
||||
"readOnlyHint": true
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -241,6 +249,9 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
tools.push(json!({
|
||||
"name": format!("calendar_timezone_option_{index}"),
|
||||
"description": format!("Read timezone option {index}."),
|
||||
"annotations": {
|
||||
"readOnlyHint": true
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call";
|
||||
pub const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms";
|
||||
pub const TOOL_CALL_UNIFIED_EXEC_METRIC: &str = "codex.tool.unified_exec";
|
||||
pub const API_CALL_COUNT_METRIC: &str = "codex.api_request";
|
||||
pub const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms";
|
||||
pub const SSE_EVENT_COUNT_METRIC: &str = "codex.sse_event";
|
||||
|
||||
@@ -22,6 +22,7 @@ use rmcp::model::ResourceTemplate;
|
||||
use rmcp::model::ServerCapabilities;
|
||||
use rmcp::model::ServerInfo;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tokio::task;
|
||||
@@ -85,11 +86,13 @@ impl TestToolServer {
|
||||
}))
|
||||
.expect("echo tool schema should deserialize");
|
||||
|
||||
Tool::new(
|
||||
let mut tool = Tool::new(
|
||||
Cow::Borrowed(name),
|
||||
Cow::Borrowed(description),
|
||||
Arc::new(schema),
|
||||
)
|
||||
);
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
tool
|
||||
}
|
||||
|
||||
fn image_tool() -> Tool {
|
||||
@@ -101,11 +104,13 @@ impl TestToolServer {
|
||||
}))
|
||||
.expect("image tool schema should deserialize");
|
||||
|
||||
Tool::new(
|
||||
let mut tool = Tool::new(
|
||||
Cow::Borrowed("image"),
|
||||
Cow::Borrowed("Return a single image content block."),
|
||||
Arc::new(schema),
|
||||
)
|
||||
);
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
tool
|
||||
}
|
||||
|
||||
/// Tool intended for manual testing of Codex TUI rendering for MCP image tool results.
|
||||
@@ -154,13 +159,15 @@ impl TestToolServer {
|
||||
}))
|
||||
.expect("image_scenario tool schema should deserialize");
|
||||
|
||||
Tool::new(
|
||||
let mut tool = Tool::new(
|
||||
Cow::Borrowed("image_scenario"),
|
||||
Cow::Borrowed(
|
||||
"Return content blocks for manual testing of MCP image rendering scenarios.",
|
||||
),
|
||||
Arc::new(schema),
|
||||
)
|
||||
);
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
tool
|
||||
}
|
||||
|
||||
fn memo_resource() -> Resource {
|
||||
|
||||
@@ -38,6 +38,7 @@ use rmcp::model::ResourceTemplate;
|
||||
use rmcp::model::ServerCapabilities;
|
||||
use rmcp::model::ServerInfo;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use rmcp::transport::StreamableHttpServerConfig;
|
||||
use rmcp::transport::StreamableHttpService;
|
||||
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
||||
@@ -84,11 +85,13 @@ impl TestToolServer {
|
||||
}))
|
||||
.expect("echo tool schema should deserialize");
|
||||
|
||||
Tool::new(
|
||||
let mut tool = Tool::new(
|
||||
Cow::Borrowed("echo"),
|
||||
Cow::Borrowed("Echo back the provided message and include environment data."),
|
||||
Arc::new(schema),
|
||||
)
|
||||
);
|
||||
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
||||
tool
|
||||
}
|
||||
|
||||
fn memo_resource() -> Resource {
|
||||
|
||||
@@ -26,6 +26,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py <plugin-nam
|
||||
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin --with-marketplace
|
||||
```
|
||||
|
||||
For a home-local plugin, treat `<home>` as the root and use:
|
||||
|
||||
```bash
|
||||
python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \
|
||||
--path ~/plugins \
|
||||
--marketplace-path ~/.agents/plugins/marketplace.json \
|
||||
--with-marketplace
|
||||
```
|
||||
|
||||
4. Generate/adjust optional companion folders as needed:
|
||||
|
||||
```bash
|
||||
@@ -37,6 +46,7 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin -
|
||||
|
||||
## What this skill creates
|
||||
|
||||
- If the user has not made the plugin location explicit, ask whether they want a repo-local plugin or a home-local plugin before generating marketplace entries.
|
||||
- Creates plugin root at `/<parent-plugin-directory>/<plugin-name>/`.
|
||||
- Always creates `/<parent-plugin-directory>/<plugin-name>/.codex-plugin/plugin.json`.
|
||||
- Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section.
|
||||
@@ -58,6 +68,8 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin -
|
||||
## Marketplace workflow
|
||||
|
||||
- `marketplace.json` always lives at `<repo-root>/.agents/plugins/marketplace.json`.
|
||||
- For a home-local plugin, use the same convention with `<home>` as the root:
|
||||
`~/.agents/plugins/marketplace.json` plus `./plugins/<plugin-name>`.
|
||||
- Marketplace root metadata supports top-level `name` plus optional `interface.displayName`.
|
||||
- Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list.
|
||||
- `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries.
|
||||
|
||||
@@ -115,8 +115,10 @@
|
||||
"source": "local",
|
||||
"path": "./plugins/linear"
|
||||
},
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_INSTALL",
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
@@ -142,7 +144,9 @@
|
||||
- `source` (`string`): Use `local` for this repo workflow.
|
||||
- `path` (`string`): Relative plugin path based on the marketplace root.
|
||||
- Repo plugin: `./plugins/<plugin-name>`
|
||||
- Local plugin in `~/.agents/plugins/marketplace.json`: `./.codex/plugins/<plugin-name>`
|
||||
- Local plugin in `~/.agents/plugins/marketplace.json`: `./plugins/<plugin-name>`
|
||||
- The same relative path convention is used for both repo-rooted and home-rooted marketplaces.
|
||||
- Example: with `~/.agents/plugins/marketplace.json`, `./plugins/<plugin-name>` resolves to `~/plugins/<plugin-name>`.
|
||||
- `policy` (`object`): Marketplace policy block. Always include it.
|
||||
- `installation` (`string`): Availability policy.
|
||||
- Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT`
|
||||
|
||||
@@ -191,7 +191,10 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
default=str(DEFAULT_PLUGIN_PARENT),
|
||||
help="Parent directory for plugin creation (defaults to <cwd>/plugins)",
|
||||
help=(
|
||||
"Parent directory for plugin creation (defaults to <cwd>/plugins). "
|
||||
"When using a home-rooted marketplace, use <home>/plugins."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory")
|
||||
parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory")
|
||||
@@ -202,12 +205,19 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"--with-marketplace",
|
||||
action="store_true",
|
||||
help="Create or update <cwd>/.agents/plugins/marketplace.json",
|
||||
help=(
|
||||
"Create or update <cwd>/.agents/plugins/marketplace.json. "
|
||||
"Marketplace entries always point to ./plugins/<plugin-name> relative to the "
|
||||
"marketplace root."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--marketplace-path",
|
||||
default=str(DEFAULT_MARKETPLACE_PATH),
|
||||
help="Path to marketplace.json (defaults to <cwd>/.agents/plugins/marketplace.json)",
|
||||
help=(
|
||||
"Path to marketplace.json (defaults to <cwd>/.agents/plugins/marketplace.json). "
|
||||
"For a home-rooted marketplace, use <home>/.agents/plugins/marketplace.json."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--install-policy",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¤t_dir, &env_map);
|
||||
for path in additional_deny_write_paths {
|
||||
if path.exists() {
|
||||
deny.insert(path.clone());
|
||||
}
|
||||
}
|
||||
let canonical_cwd = canonicalize_path(¤t_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
|
||||
|
||||
Reference in New Issue
Block a user