mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Merge branch 'main' into skills/crate
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;
|
||||
@@ -286,6 +287,7 @@ use codex_state::log_db::LogDbLayer;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_json_to_toml::json_to_toml;
|
||||
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
@@ -374,7 +376,8 @@ pub(crate) struct CodexMessageProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
config: Arc<Config>,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
|
||||
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
|
||||
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
|
||||
active_login: Arc<Mutex<Option<ActiveLogin>>>,
|
||||
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
|
||||
@@ -425,7 +428,8 @@ pub(crate) struct CodexMessageProcessorArgs {
|
||||
pub(crate) outgoing: Arc<OutgoingMessageSender>,
|
||||
pub(crate) arg0_paths: Arg0DispatchPaths,
|
||||
pub(crate) config: Arc<Config>,
|
||||
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
|
||||
pub(crate) cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
|
||||
pub(crate) runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
|
||||
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
|
||||
pub(crate) feedback: CodexFeedback,
|
||||
pub(crate) log_db: Option<LogDbLayer>,
|
||||
@@ -489,6 +493,7 @@ impl CodexMessageProcessor {
|
||||
arg0_paths,
|
||||
config,
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
cloud_requirements,
|
||||
feedback,
|
||||
log_db,
|
||||
@@ -500,6 +505,7 @@ impl CodexMessageProcessor {
|
||||
arg0_paths,
|
||||
config,
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
cloud_requirements,
|
||||
active_login: Arc::new(Mutex::new(None)),
|
||||
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -520,7 +526,7 @@ impl CodexMessageProcessor {
|
||||
) -> Result<Config, JSONRPCErrorError> {
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.cli_overrides(self.cli_overrides.clone())
|
||||
.cli_overrides(self.current_cli_overrides())
|
||||
.fallback_cwd(fallback_cwd)
|
||||
.cloud_requirements(cloud_requirements)
|
||||
.build()
|
||||
@@ -530,6 +536,7 @@ impl CodexMessageProcessor {
|
||||
message: format!("failed to reload config: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
|
||||
config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
|
||||
config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone();
|
||||
Ok(config)
|
||||
@@ -542,6 +549,20 @@ impl CodexMessageProcessor {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
|
||||
self.cli_overrides
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
|
||||
self.runtime_feature_enablement
|
||||
.read()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// If a client sends `developer_instructions: null` during a mode switch,
|
||||
/// use the built-in instructions for that mode.
|
||||
fn normalize_turn_start_collaboration_mode(
|
||||
@@ -887,7 +908,8 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
ClientRequest::ConfigRead { .. }
|
||||
| ClientRequest::ConfigValueWrite { .. }
|
||||
| ClientRequest::ConfigBatchWrite { .. } => {
|
||||
| ClientRequest::ConfigBatchWrite { .. }
|
||||
| ClientRequest::ExperimentalFeatureEnablementSet { .. } => {
|
||||
warn!("Config request reached CodexMessageProcessor unexpectedly");
|
||||
}
|
||||
ClientRequest::FsReadFile { .. }
|
||||
@@ -1088,7 +1110,7 @@ impl CodexMessageProcessor {
|
||||
let cloud_requirements = self.cloud_requirements.clone();
|
||||
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
let cli_overrides = self.cli_overrides.clone();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let auth_url = server.auth_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = match tokio::time::timeout(
|
||||
@@ -1276,11 +1298,9 @@ impl CodexMessageProcessor {
|
||||
self.config.chatgpt_base_url.clone(),
|
||||
self.config.codex_home.clone(),
|
||||
);
|
||||
sync_default_client_residency_requirement(
|
||||
&self.cli_overrides,
|
||||
self.cloud_requirements.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref())
|
||||
.await;
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {})
|
||||
@@ -1895,8 +1915,8 @@ impl CodexMessageProcessor {
|
||||
personality,
|
||||
);
|
||||
typesafe_overrides.ephemeral = ephemeral;
|
||||
let cli_overrides = self.cli_overrides.clone();
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let listener_task_context = ListenerTaskContext {
|
||||
thread_manager: Arc::clone(&self.thread_manager),
|
||||
thread_state_manager: self.thread_state_manager.clone(),
|
||||
@@ -1906,10 +1926,12 @@ impl CodexMessageProcessor {
|
||||
codex_home: self.config.codex_home.clone(),
|
||||
};
|
||||
let request_trace = request_context.request_trace();
|
||||
let runtime_feature_enablement = self.current_runtime_feature_enablement();
|
||||
let thread_start_task = async move {
|
||||
Self::thread_start_task(
|
||||
listener_task_context,
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
cloud_requirements,
|
||||
request_id,
|
||||
config,
|
||||
@@ -1982,6 +2004,7 @@ impl CodexMessageProcessor {
|
||||
async fn thread_start_task(
|
||||
listener_task_context: ListenerTaskContext,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
runtime_feature_enablement: BTreeMap<String, bool>,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
request_id: ConnectionRequestId,
|
||||
config_overrides: Option<HashMap<String, serde_json::Value>>,
|
||||
@@ -1998,6 +2021,7 @@ impl CodexMessageProcessor {
|
||||
typesafe_overrides,
|
||||
&cloud_requirements,
|
||||
&listener_task_context.codex_home,
|
||||
&runtime_feature_enablement,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3500,13 +3524,16 @@ impl CodexMessageProcessor {
|
||||
|
||||
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let runtime_feature_enablement = self.current_runtime_feature_enablement();
|
||||
let config = match derive_config_for_cwd(
|
||||
&self.cli_overrides,
|
||||
&cli_overrides,
|
||||
request_overrides,
|
||||
typesafe_overrides,
|
||||
history_cwd,
|
||||
&cloud_requirements,
|
||||
&self.config.codex_home,
|
||||
&runtime_feature_enablement,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4042,13 +4069,16 @@ impl CodexMessageProcessor {
|
||||
typesafe_overrides.ephemeral = ephemeral.then_some(true);
|
||||
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let runtime_feature_enablement = self.current_runtime_feature_enablement();
|
||||
let config = match derive_config_for_cwd(
|
||||
&self.cli_overrides,
|
||||
&cli_overrides,
|
||||
request_overrides,
|
||||
typesafe_overrides,
|
||||
history_cwd,
|
||||
&cloud_requirements,
|
||||
&self.config.codex_home,
|
||||
&runtime_feature_enablement,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -7277,12 +7307,13 @@ impl CodexMessageProcessor {
|
||||
WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated,
|
||||
};
|
||||
let config = Arc::clone(&self.config);
|
||||
let cli_overrides = self.cli_overrides.clone();
|
||||
let cloud_requirements = self.current_cloud_requirements();
|
||||
let command_cwd = params
|
||||
.cwd
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| config.cwd.clone());
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let runtime_feature_enablement = self.current_runtime_feature_enablement();
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let connection_id = request_id.connection_id;
|
||||
|
||||
@@ -7297,6 +7328,7 @@ impl CodexMessageProcessor {
|
||||
Some(command_cwd.clone()),
|
||||
&cloud_requirements,
|
||||
&config.codex_home,
|
||||
&runtime_feature_enablement,
|
||||
)
|
||||
.await;
|
||||
let setup_result = match derived_config {
|
||||
@@ -7926,6 +7958,7 @@ async fn derive_config_from_params(
|
||||
typesafe_overrides: ConfigOverrides,
|
||||
cloud_requirements: &CloudRequirementsLoader,
|
||||
codex_home: &Path,
|
||||
runtime_feature_enablement: &BTreeMap<String, bool>,
|
||||
) -> std::io::Result<Config> {
|
||||
let merged_cli_overrides = cli_overrides
|
||||
.iter()
|
||||
@@ -7938,13 +7971,15 @@ async fn derive_config_from_params(
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
codex_core::config::ConfigBuilder::default()
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.cli_overrides(merged_cli_overrides)
|
||||
.harness_overrides(typesafe_overrides)
|
||||
.cloud_requirements(cloud_requirements.clone())
|
||||
.build()
|
||||
.await
|
||||
.await?;
|
||||
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn derive_config_for_cwd(
|
||||
@@ -7954,6 +7989,7 @@ async fn derive_config_for_cwd(
|
||||
cwd: Option<PathBuf>,
|
||||
cloud_requirements: &CloudRequirementsLoader,
|
||||
codex_home: &Path,
|
||||
runtime_feature_enablement: &BTreeMap<String, bool>,
|
||||
) -> std::io::Result<Config> {
|
||||
let merged_cli_overrides = cli_overrides
|
||||
.iter()
|
||||
@@ -7966,14 +8002,16 @@ async fn derive_config_for_cwd(
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
codex_core::config::ConfigBuilder::default()
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.cli_overrides(merged_cli_overrides)
|
||||
.harness_overrides(typesafe_overrides)
|
||||
.fallback_cwd(cwd)
|
||||
.cloud_requirements(cloud_requirements.clone())
|
||||
.build()
|
||||
.await
|
||||
.await?;
|
||||
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn read_history_cwd_from_state_db(
|
||||
|
||||
@@ -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| {
|
||||
@@ -267,6 +453,7 @@ mod tests {
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
@@ -394,6 +581,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\napps = false\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cli_overrides(vec![(
|
||||
"features.apps".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
apply_runtime_feature_enablement(
|
||||
&mut config,
|
||||
&BTreeMap::from([("apps".to_string(), false)]),
|
||||
);
|
||||
|
||||
assert!(config.features.enabled(Feature::Apps));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
|
||||
let mut config = codex_core::config::ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cli_overrides(vec![(
|
||||
"features.apps".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
feature_requirements: Some(
|
||||
codex_core::config_loader::FeatureRequirementsToml {
|
||||
entries: BTreeMap::from([("apps".to_string(), false)]),
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
apply_runtime_feature_enablement(
|
||||
&mut config,
|
||||
&BTreeMap::from([("apps".to_string(), true)]),
|
||||
);
|
||||
|
||||
assert!(!config.features.enabled(Feature::Apps));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_write_reloads_user_config_when_requested() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
@@ -409,7 +656,8 @@ mod tests {
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let config_api = ConfigApi::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
Vec::new(),
|
||||
Arc::new(RwLock::new(Vec::new())),
|
||||
Arc::new(RwLock::new(BTreeMap::new())),
|
||||
LoaderOverrides::default(),
|
||||
Arc::new(RwLock::new(CloudRequirementsLoader::default())),
|
||||
reloader.clone(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
@@ -30,6 +31,7 @@ use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::ExperimentalApi;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
||||
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
|
||||
use codex_app_server_protocol::ExternalAgentConfigImportParams;
|
||||
use codex_app_server_protocol::FsCopyParams;
|
||||
@@ -229,6 +231,8 @@ impl MessageProcessor {
|
||||
.plugins_manager()
|
||||
.set_analytics_events_client(analytics_events_client.clone());
|
||||
|
||||
let cli_overrides = Arc::new(RwLock::new(cli_overrides));
|
||||
let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new()));
|
||||
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
|
||||
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
|
||||
auth_manager: auth_manager.clone(),
|
||||
@@ -237,6 +241,7 @@ impl MessageProcessor {
|
||||
arg0_paths,
|
||||
config: Arc::clone(&config),
|
||||
cli_overrides: cli_overrides.clone(),
|
||||
runtime_feature_enablement: runtime_feature_enablement.clone(),
|
||||
cloud_requirements: cloud_requirements.clone(),
|
||||
feedback,
|
||||
log_db,
|
||||
@@ -249,6 +254,7 @@ impl MessageProcessor {
|
||||
let config_api = ConfigApi::new(
|
||||
config.codex_home.clone(),
|
||||
cli_overrides,
|
||||
runtime_feature_enablement,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
thread_manager,
|
||||
@@ -703,6 +709,16 @@ impl MessageProcessor {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => {
|
||||
self.handle_experimental_feature_enablement_set(
|
||||
ConnectionRequestId {
|
||||
connection_id,
|
||||
request_id,
|
||||
},
|
||||
params,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ConfigRequirementsRead {
|
||||
request_id,
|
||||
params: _,
|
||||
@@ -851,7 +867,30 @@ impl MessageProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
params: ConfigBatchWriteParams,
|
||||
) {
|
||||
match self.config_api.batch_write(params).await {
|
||||
self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn handle_experimental_feature_enablement_set(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ExperimentalFeatureEnablementSetParams,
|
||||
) {
|
||||
self.handle_config_mutation_result(
|
||||
request_id,
|
||||
self.config_api
|
||||
.set_experimental_feature_enablement(params)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn handle_config_mutation_result<T: serde::Serialize>(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
result: std::result::Result<T, JSONRPCErrorError>,
|
||||
) {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
self.codex_message_processor.clear_plugin_related_caches();
|
||||
self.codex_message_processor
|
||||
|
||||
@@ -465,6 +465,16 @@ impl McpProcess {
|
||||
self.send_request("experimentalFeature/list", params).await
|
||||
}
|
||||
|
||||
/// Send an `experimentalFeature/enablement/set` JSON-RPC request.
|
||||
pub async fn send_experimental_feature_enablement_set_request(
|
||||
&mut self,
|
||||
params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("experimentalFeature/enablement/set", params)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send an `app/list` JSON-RPC request.
|
||||
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
|
||||
@@ -3,16 +3,24 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigReadResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeature;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListParams;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListResponse;
|
||||
use codex_app_server_protocol::ExperimentalFeatureStage;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -34,13 +42,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
|
||||
.send_experimental_feature_list_request(ExperimentalFeatureListParams::default())
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let actual = to_response::<ExperimentalFeatureListResponse>(response)?;
|
||||
let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?;
|
||||
let expected_data = FEATURES
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
@@ -82,3 +84,240 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
|
||||
assert_eq!(actual, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let project_cwd = codex_home.path().join("project");
|
||||
std::fs::create_dir_all(&project_cwd)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let actual =
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([("apps".to_string(), true)]),
|
||||
}
|
||||
);
|
||||
|
||||
for cwd in [None, Some(project_cwd.display().to_string())] {
|
||||
let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\napps = false\n",
|
||||
)?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let actual =
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([("apps".to_string(), true)]),
|
||||
}
|
||||
);
|
||||
|
||||
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
Some(&json!(false))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
let actual = set_experimental_feature_enablement(
|
||||
&mut mcp,
|
||||
BTreeMap::from([
|
||||
("plugins".to_string(), true),
|
||||
("tool_search".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([
|
||||
("plugins".to_string(), true),
|
||||
("tool_search".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
]),
|
||||
}
|
||||
);
|
||||
|
||||
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("plugins")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("tool_search")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("tool_suggest")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("tool_call_mcp_elicitation")),
|
||||
Some(&json!(false))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?;
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::new(),
|
||||
}
|
||||
);
|
||||
|
||||
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
|
||||
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
||||
enablement: BTreeMap::from([("personality".to_string(), true)]),
|
||||
})
|
||||
.await?;
|
||||
let JSONRPCError { error, .. } = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.code, -32600);
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.contains("unsupported feature enablement `personality`"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_experimental_feature_enablement(
|
||||
mcp: &mut McpProcess,
|
||||
enablement: BTreeMap<String, bool>,
|
||||
) -> Result<ExperimentalFeatureEnablementSetResponse> {
|
||||
let request_id = mcp
|
||||
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
||||
enablement,
|
||||
})
|
||||
.await?;
|
||||
read_response(mcp, request_id).await
|
||||
}
|
||||
|
||||
async fn read_config(mcp: &mut McpProcess, cwd: Option<String>) -> Result<ConfigReadResponse> {
|
||||
let request_id = mcp
|
||||
.send_config_read_request(ConfigReadParams {
|
||||
include_layers: false,
|
||||
cwd,
|
||||
})
|
||||
.await?;
|
||||
read_response(mcp, request_id).await
|
||||
}
|
||||
|
||||
async fn read_response<T: DeserializeOwned>(mcp: &mut McpProcess, request_id: i64) -> Result<T> {
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response(response)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -2220,15 +2220,6 @@ impl Session {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
}
|
||||
|
||||
// Append the current session's initial context after the reconstructed history.
|
||||
let initial_context = self.build_initial_context(&turn_context).await;
|
||||
self.record_conversation_items(&turn_context, &initial_context)
|
||||
.await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
|
||||
}
|
||||
|
||||
// Forked threads should remain file-backed immediately after startup.
|
||||
self.ensure_rollout_materialized().await;
|
||||
|
||||
|
||||
@@ -1115,18 +1115,12 @@ async fn recompute_token_usage_updates_model_context_window() {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
let reconstruction_turn = session.new_default_turn().await;
|
||||
expected.extend(
|
||||
session
|
||||
.build_initial_context(reconstruction_turn.as_ref())
|
||||
.await,
|
||||
);
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -1244,7 +1238,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
|
||||
|
||||
let request = first_forked_request.single_request();
|
||||
let snapshot = context_snapshot::format_labeled_requests_snapshot(
|
||||
"First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.",
|
||||
"First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.",
|
||||
&[("First Forked Turn Request", &request)],
|
||||
&ContextSnapshotOptions::default()
|
||||
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
|
||||
@@ -1309,7 +1303,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::TurnContext(previous_context_item.clone()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
turn_id,
|
||||
@@ -1322,6 +1316,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
let history = session.clone_history().await;
|
||||
assert_eq!(
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
@@ -1329,6 +1324,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
assert_eq!(history.raw_items(), &[]);
|
||||
assert_eq!(
|
||||
serde_json::to_value(session.reference_context_item().await)
|
||||
.expect("serialize fork reference context item"),
|
||||
serde_json::to_value(Some(previous_context_item))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -193,6 +193,10 @@ pub(crate) fn build_settings_update_items(
|
||||
exec_policy: &Policy,
|
||||
personality_feature_enabled: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
// TODO(ccunningham): build_settings_update_items still does not cover every
|
||||
// model-visible item emitted by build_initial_context. Persist the remaining
|
||||
// inputs or add explicit replay events so fork/resume can diff everything
|
||||
// deterministically.
|
||||
let contextual_user_message = build_environment_update_item(previous, next, shell);
|
||||
let developer_update_sections = [
|
||||
// Keep model-switch instructions first so model-specific guidance is read before
|
||||
|
||||
+210
-58
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -1,17 +1,15 @@
|
||||
---
|
||||
source: core/src/codex_tests.rs
|
||||
assertion_line: 1282
|
||||
assertion_line: 1254
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.
|
||||
Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.
|
||||
|
||||
## First Forked Turn Request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:fork seed
|
||||
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
05:message/developer[2]:
|
||||
03:message/developer[2]:
|
||||
[01] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
|
||||
06:message/user:after fork
|
||||
04:message/user:after fork
|
||||
|
||||
@@ -175,6 +175,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
|
||||
windows_restricted_token_filesystem_overlay: None,
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay: _windows_restricted_token_filesystem_overlay,
|
||||
arg0,
|
||||
} = sandbox_exec_request;
|
||||
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
|
||||
@@ -918,6 +919,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
sandbox_policy: self.sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
|
||||
network_sandbox_policy: self.network_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay: None,
|
||||
arg0: self.arg0.clone(),
|
||||
},
|
||||
/*stdout_stream*/ None,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1858,6 +1858,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() {
|
||||
let model_info = search_capable_model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -1976,7 +1977,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_requires_model_capability_only() {
|
||||
fn search_tool_requires_model_capability_and_feature_flag() {
|
||||
let model_info = search_capable_model_info();
|
||||
let app_tools = Some(HashMap::from([(
|
||||
"mcp__codex_apps__calendar_create_event".to_string(),
|
||||
@@ -2012,6 +2013,22 @@ fn search_tool_requires_model_capability_only() {
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
||||
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
||||
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2030,8 +2047,8 @@ fn search_tool_requires_model_capability_only() {
|
||||
fn tool_suggest_is_not_registered_without_feature_flag() {
|
||||
let model_info = search_capable_model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::Plugins);
|
||||
features.enable(Feature::ToolSearch);
|
||||
features.disable(Feature::ToolSuggest);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2074,12 +2091,11 @@ fn tool_suggest_requires_apps_and_plugins_features() {
|
||||
|
||||
for disabled_feature in [Feature::Apps, Feature::Plugins] {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::ToolSearch);
|
||||
features.enable(Feature::ToolSuggest);
|
||||
for feature in [Feature::Apps, Feature::Plugins] {
|
||||
if feature != disabled_feature {
|
||||
features.enable(feature);
|
||||
}
|
||||
}
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::Plugins);
|
||||
features.disable(disabled_feature);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2113,6 +2129,7 @@ fn search_tool_description_handles_no_enabled_apps() {
|
||||
let model_info = search_capable_model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2139,6 +2156,7 @@ fn search_tool_description_falls_back_to_connector_name_without_description() {
|
||||
let model_info = search_capable_model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2187,6 +2205,7 @@ fn search_tool_registers_namespaced_app_tool_aliases() {
|
||||
let model_info = search_capable_model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -2253,6 +2272,7 @@ fn tool_suggest_description_lists_discoverable_tools() {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::Plugins);
|
||||
features.enable(Feature::ToolSearch);
|
||||
features.enable(Feature::ToolSuggest);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
|
||||
@@ -125,9 +125,8 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
|
||||
// GetHistory on fork1 flushed; the file is ready.
|
||||
let fork1_items = read_items(&fork1_path);
|
||||
assert!(fork1_items.len() > expected_after_first.len());
|
||||
pretty_assertions::assert_eq!(
|
||||
serde_json::to_value(&fork1_items[..expected_after_first.len()]).unwrap(),
|
||||
serde_json::to_value(&fork1_items).unwrap(),
|
||||
serde_json::to_value(&expected_after_first).unwrap()
|
||||
);
|
||||
|
||||
@@ -156,9 +155,8 @@ async fn fork_thread_twice_drops_to_first_message() {
|
||||
.unwrap_or(0);
|
||||
let expected_after_second: Vec<RolloutItem> = fork1_items[..cut_last_on_fork1].to_vec();
|
||||
let fork2_items = read_items(&fork2_path);
|
||||
assert!(fork2_items.len() > expected_after_second.len());
|
||||
pretty_assertions::assert_eq!(
|
||||
serde_json::to_value(&fork2_items[..expected_after_second.len()]).unwrap(),
|
||||
serde_json::to_value(&fork2_items).unwrap(),
|
||||
serde_json::to_value(&expected_after_second).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,14 +443,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
let body4 = req4.single_request().body_json();
|
||||
let input4 = body4["input"].as_array().expect("input array");
|
||||
let permissions_fork = permissions_texts(input4);
|
||||
assert_eq!(permissions_fork.len(), permissions_base.len() + 2);
|
||||
assert_eq!(permissions_fork.len(), permissions_base.len() + 1);
|
||||
assert_eq!(
|
||||
&permissions_fork[..permissions_base.len()],
|
||||
permissions_base.as_slice()
|
||||
);
|
||||
let new_permissions = &permissions_fork[permissions_base.len()..];
|
||||
assert_eq!(new_permissions.len(), 2);
|
||||
assert_eq!(new_permissions[0], new_permissions[1]);
|
||||
assert_eq!(new_permissions.len(), 1);
|
||||
assert_eq!(permissions_fork, permissions_resume);
|
||||
assert!(!permissions_base.contains(&new_permissions[0]));
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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