mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
Refresh codex-rs mirror to upstream/main
This commit is contained in:
@@ -88,7 +88,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
@@ -2565,6 +2565,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"cwd": {
|
||||
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional page size; defaults to a reasonable server-side value.",
|
||||
"format": "uint32",
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -193,6 +193,11 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEnabled": {
|
||||
"default": true,
|
||||
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -241,7 +246,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
@@ -10201,6 +10201,11 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEnabled": {
|
||||
"default": true,
|
||||
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -15329,6 +15334,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"cwd": {
|
||||
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional page size; defaults to a reasonable server-side value.",
|
||||
"format": "uint32",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEnabled": {
|
||||
"default": true,
|
||||
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEnabled": {
|
||||
"default": true,
|
||||
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
|
||||
"type": "boolean"
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"cwd": {
|
||||
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional page size; defaults to a reasonable server-side value.",
|
||||
"format": "uint32",
|
||||
|
||||
@@ -5,4 +5,13 @@
|
||||
/**
|
||||
* EXPERIMENTAL - app metadata returned by app-list APIs.
|
||||
*/
|
||||
export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, };
|
||||
export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean,
|
||||
/**
|
||||
* Whether this app is enabled in config.toml.
|
||||
* Example:
|
||||
* ```toml
|
||||
* [apps.bad_app]
|
||||
* enabled = false
|
||||
* ```
|
||||
*/
|
||||
isEnabled: boolean, };
|
||||
|
||||
@@ -31,4 +31,9 @@ sourceKinds?: Array<ThreadSourceKind> | null,
|
||||
* Optional archived filter; when set to true, only archived threads are returned.
|
||||
* If false or null, only non-archived threads are returned.
|
||||
*/
|
||||
archived?: boolean | null, };
|
||||
archived?: boolean | null,
|
||||
/**
|
||||
* Optional cwd filter; when set, only threads whose session cwd exactly
|
||||
* matches this path are returned.
|
||||
*/
|
||||
cwd?: string | null, };
|
||||
|
||||
@@ -1290,6 +1290,14 @@ pub struct AppInfo {
|
||||
pub install_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_accessible: bool,
|
||||
/// Whether this app is enabled in config.toml.
|
||||
/// Example:
|
||||
/// ```toml
|
||||
/// [apps.bad_app]
|
||||
/// enabled = false
|
||||
/// ```
|
||||
#[serde(default = "default_enabled")]
|
||||
pub is_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -1700,6 +1708,10 @@ pub struct ThreadListParams {
|
||||
/// If false or null, only non-archived threads are returned.
|
||||
#[ts(optional = nullable)]
|
||||
pub archived: Option<bool>,
|
||||
/// Optional cwd filter; when set, only threads whose session cwd exactly
|
||||
/// matches this path are returned.
|
||||
#[ts(optional = nullable)]
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -117,7 +117,7 @@ Example with notification opt-out:
|
||||
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` and auto-subscribes you to turn/item events for the new thread.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, and `cwd` filters.
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`.
|
||||
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success.
|
||||
@@ -221,6 +221,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per
|
||||
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
|
||||
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
|
||||
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
|
||||
- `cwd` — restrict results to threads whose session cwd exactly matches this path.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -763,7 +764,7 @@ To enable or disable a skill by path:
|
||||
|
||||
## Apps
|
||||
|
||||
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible.
|
||||
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, whether it is currently accessible, and whether it is enabled in config.
|
||||
|
||||
```json
|
||||
{ "method": "app/list", "id": 50, "params": {
|
||||
@@ -782,7 +783,8 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat
|
||||
"logoUrlDark": null,
|
||||
"distributionChannel": null,
|
||||
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
|
||||
"isAccessible": true
|
||||
"isAccessible": true,
|
||||
"isEnabled": true
|
||||
}
|
||||
],
|
||||
"nextCursor": null
|
||||
@@ -808,7 +810,8 @@ The server also emits `app/list/updated` notifications whenever either source (a
|
||||
"logoUrlDark": null,
|
||||
"distributionChannel": null,
|
||||
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
|
||||
"isAccessible": true
|
||||
"isAccessible": true,
|
||||
"isEnabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::codex_message_processor::read_summary_from_rollout;
|
||||
use crate::codex_message_processor::summary_to_thread;
|
||||
use crate::error_code::INTERNAL_ERROR_CODE;
|
||||
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
|
||||
use crate::outgoing_message::ClientRequestResult;
|
||||
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
|
||||
use crate::thread_state::ThreadState;
|
||||
use crate::thread_state::TurnSummary;
|
||||
@@ -1408,12 +1409,25 @@ async fn handle_error(
|
||||
|
||||
async fn on_patch_approval_response(
|
||||
call_id: String,
|
||||
receiver: oneshot::Receiver<JsonValue>,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
codex: Arc<CodexThread>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
let value = match response {
|
||||
Ok(value) => value,
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
if let Err(submit_err) = codex
|
||||
.submit(Op::PatchApproval {
|
||||
id: call_id.clone(),
|
||||
decision: ReviewDecision::Denied,
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!("failed to submit denied PatchApproval after request failure: {submit_err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
if let Err(submit_err) = codex
|
||||
@@ -1451,12 +1465,16 @@ async fn on_patch_approval_response(
|
||||
async fn on_exec_approval_response(
|
||||
call_id: String,
|
||||
turn_id: String,
|
||||
receiver: oneshot::Receiver<JsonValue>,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
conversation: Arc<CodexThread>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
let value = match response {
|
||||
Ok(value) => value,
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
return;
|
||||
@@ -1488,12 +1506,28 @@ async fn on_exec_approval_response(
|
||||
|
||||
async fn on_request_user_input_response(
|
||||
event_turn_id: String,
|
||||
receiver: oneshot::Receiver<JsonValue>,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
conversation: Arc<CodexThread>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
let value = match response {
|
||||
Ok(value) => value,
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
let empty = CoreRequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
};
|
||||
if let Err(err) = conversation
|
||||
.submit(Op::UserInputAnswer {
|
||||
id: event_turn_id,
|
||||
response: empty,
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!("failed to submit UserInputAnswer: {err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
let empty = CoreRequestUserInputResponse {
|
||||
@@ -1628,14 +1662,14 @@ async fn on_file_change_request_approval_response(
|
||||
conversation_id: ThreadId,
|
||||
item_id: String,
|
||||
changes: Vec<FileUpdateChange>,
|
||||
receiver: oneshot::Receiver<JsonValue>,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
codex: Arc<CodexThread>,
|
||||
outgoing: ThreadScopedOutgoingMessageSender,
|
||||
thread_state: Arc<Mutex<ThreadState>>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
let (decision, completion_status) = match response {
|
||||
Ok(value) => {
|
||||
Ok(Ok(value)) => {
|
||||
let response = serde_json::from_value::<FileChangeRequestApprovalResponse>(value)
|
||||
.unwrap_or_else(|err| {
|
||||
error!("failed to deserialize FileChangeRequestApprovalResponse: {err}");
|
||||
@@ -1650,6 +1684,10 @@ async fn on_file_change_request_approval_response(
|
||||
// Only short-circuit on declines/cancels/failures.
|
||||
(decision, completion_status)
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
|
||||
@@ -1688,13 +1726,13 @@ async fn on_command_execution_request_approval_response(
|
||||
command: String,
|
||||
cwd: PathBuf,
|
||||
command_actions: Vec<V2ParsedCommand>,
|
||||
receiver: oneshot::Receiver<JsonValue>,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
conversation: Arc<CodexThread>,
|
||||
outgoing: ThreadScopedOutgoingMessageSender,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
let (decision, completion_status) = match response {
|
||||
Ok(value) => {
|
||||
Ok(Ok(value)) => {
|
||||
let response = serde_json::from_value::<CommandExecutionRequestApprovalResponse>(value)
|
||||
.unwrap_or_else(|err| {
|
||||
error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}");
|
||||
@@ -1729,6 +1767,10 @@ async fn on_command_execution_request_approval_response(
|
||||
};
|
||||
(decision, completion_status)
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
(ReviewDecision::Denied, Some(CommandExecutionStatus::Failed))
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
(ReviewDecision::Denied, Some(CommandExecutionStatus::Failed))
|
||||
|
||||
@@ -266,6 +266,13 @@ use crate::thread_state::ThreadStateManager;
|
||||
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
|
||||
const THREAD_LIST_MAX_LIMIT: usize = 100;
|
||||
|
||||
struct ThreadListFilters {
|
||||
model_providers: Option<Vec<String>>,
|
||||
source_kinds: Option<Vec<ThreadSourceKind>>,
|
||||
archived: bool,
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
// Duration before a ChatGPT login attempt is abandoned.
|
||||
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
@@ -1677,11 +1684,11 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone());
|
||||
let env = create_env(&self.config.shell_environment_policy, None);
|
||||
let env = create_env(&self.config.permissions.shell_environment_policy, None);
|
||||
let timeout_ms = params
|
||||
.timeout_ms
|
||||
.and_then(|timeout_ms| u64::try_from(timeout_ms).ok());
|
||||
let started_network_proxy = match self.config.network.as_ref() {
|
||||
let started_network_proxy = match self.config.permissions.network.as_ref() {
|
||||
Some(spec) => match spec.start_proxy().await {
|
||||
Ok(started) => Some(started),
|
||||
Err(err) => {
|
||||
@@ -1713,7 +1720,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
let requested_policy = params.sandbox_policy.map(|policy| policy.to_core());
|
||||
let effective_policy = match requested_policy {
|
||||
Some(policy) => match self.config.sandbox_policy.can_set(&policy) {
|
||||
Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) {
|
||||
Ok(()) => policy,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
@@ -1725,7 +1732,7 @@ impl CodexMessageProcessor {
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => self.config.sandbox_policy.get().clone(),
|
||||
None => self.config.permissions.sandbox_policy.get().clone(),
|
||||
};
|
||||
|
||||
let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone();
|
||||
@@ -2430,6 +2437,7 @@ impl CodexMessageProcessor {
|
||||
model_providers,
|
||||
source_kinds,
|
||||
archived,
|
||||
cwd,
|
||||
} = params;
|
||||
|
||||
let requested_page_size = limit
|
||||
@@ -2444,10 +2452,13 @@ impl CodexMessageProcessor {
|
||||
.list_threads_common(
|
||||
requested_page_size,
|
||||
cursor,
|
||||
model_providers,
|
||||
source_kinds,
|
||||
core_sort_key,
|
||||
archived.unwrap_or(false),
|
||||
ThreadListFilters {
|
||||
model_providers,
|
||||
source_kinds,
|
||||
archived: archived.unwrap_or(false),
|
||||
cwd: cwd.map(PathBuf::from),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3221,10 +3232,13 @@ impl CodexMessageProcessor {
|
||||
.list_threads_common(
|
||||
requested_page_size,
|
||||
cursor,
|
||||
model_providers,
|
||||
None,
|
||||
CoreThreadSortKey::UpdatedAt,
|
||||
false,
|
||||
ThreadListFilters {
|
||||
model_providers,
|
||||
source_kinds: None,
|
||||
archived: false,
|
||||
cwd: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3242,11 +3256,15 @@ impl CodexMessageProcessor {
|
||||
&self,
|
||||
requested_page_size: usize,
|
||||
cursor: Option<String>,
|
||||
model_providers: Option<Vec<String>>,
|
||||
source_kinds: Option<Vec<ThreadSourceKind>>,
|
||||
sort_key: CoreThreadSortKey,
|
||||
archived: bool,
|
||||
filters: ThreadListFilters,
|
||||
) -> Result<(Vec<ConversationSummary>, Option<String>), JSONRPCErrorError> {
|
||||
let ThreadListFilters {
|
||||
model_providers,
|
||||
source_kinds,
|
||||
archived,
|
||||
cwd,
|
||||
} = filters;
|
||||
let mut cursor_obj: Option<RolloutCursor> = match cursor.as_ref() {
|
||||
Some(cursor_str) => {
|
||||
Some(parse_cursor(cursor_str).ok_or_else(|| JSONRPCErrorError {
|
||||
@@ -3327,6 +3345,9 @@ impl CodexMessageProcessor {
|
||||
if source_kind_filter
|
||||
.as_ref()
|
||||
.is_none_or(|filter| source_kind_matches(&summary.source, filter))
|
||||
&& cwd
|
||||
.as_ref()
|
||||
.is_none_or(|expected_cwd| &summary.cwd == expected_cwd)
|
||||
{
|
||||
filtered.push(summary);
|
||||
if filtered.len() >= remaining {
|
||||
@@ -4603,8 +4624,9 @@ impl CodexMessageProcessor {
|
||||
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
|
||||
});
|
||||
|
||||
let all_config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = connectors::list_all_connectors_with_options(&config, force_refetch)
|
||||
let result = connectors::list_all_connectors_with_options(&all_config, force_refetch)
|
||||
.await
|
||||
.map_err(|err| format!("failed to list apps: {err}"));
|
||||
let _ = tx.send(AppListLoadResult::Directory(result));
|
||||
@@ -4667,9 +4689,12 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
let merged = Self::merge_loaded_apps(
|
||||
all_connectors.as_deref(),
|
||||
accessible_connectors.as_deref(),
|
||||
let merged = connectors::with_app_enabled_state(
|
||||
Self::merge_loaded_apps(
|
||||
all_connectors.as_deref(),
|
||||
accessible_connectors.as_deref(),
|
||||
),
|
||||
&config,
|
||||
);
|
||||
Self::send_app_list_updated_notification(&outgoing, merged.clone()).await;
|
||||
|
||||
@@ -5152,10 +5177,13 @@ impl CodexMessageProcessor {
|
||||
&mut self,
|
||||
request_id: &ConnectionRequestId,
|
||||
parent_thread_id: ThreadId,
|
||||
parent_thread: Arc<CodexThread>,
|
||||
review_request: ReviewRequest,
|
||||
display_text: &str,
|
||||
) -> std::result::Result<(), JSONRPCErrorError> {
|
||||
let rollout_path =
|
||||
let rollout_path = if let Some(path) = parent_thread.rollout_path() {
|
||||
path
|
||||
} else {
|
||||
find_thread_path_by_id_str(&self.config.codex_home, &parent_thread_id.to_string())
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
@@ -5167,7 +5195,8 @@ impl CodexMessageProcessor {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("no rollout found for thread id {parent_thread_id}"),
|
||||
data: None,
|
||||
})?;
|
||||
})?
|
||||
};
|
||||
|
||||
let mut config = self.config.as_ref().clone();
|
||||
if let Some(review_model) = &config.review_model {
|
||||
@@ -5290,6 +5319,7 @@ impl CodexMessageProcessor {
|
||||
.start_detached_review(
|
||||
&request_id,
|
||||
parent_thread_id,
|
||||
parent_thread,
|
||||
review_request,
|
||||
display_text.as_str(),
|
||||
)
|
||||
|
||||
@@ -7,14 +7,35 @@ use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::error;
|
||||
|
||||
use crate::outgoing_message::ClientRequestResult;
|
||||
|
||||
pub(crate) async fn on_call_response(
|
||||
call_id: String,
|
||||
receiver: oneshot::Receiver<serde_json::Value>,
|
||||
receiver: oneshot::Receiver<ClientRequestResult>,
|
||||
conversation: Arc<CodexThread>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
let value = match response {
|
||||
Ok(value) => value,
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(err)) => {
|
||||
error!("request failed with client error: {err:?}");
|
||||
let fallback = CoreDynamicToolResponse {
|
||||
content_items: vec![CoreDynamicToolCallOutputContentItem::InputText {
|
||||
text: "dynamic tool request failed".to_string(),
|
||||
}],
|
||||
success: false,
|
||||
};
|
||||
if let Err(err) = conversation
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: call_id.clone(),
|
||||
response: fallback,
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!("failed to submit DynamicToolResponse: {err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
let fallback = CoreDynamicToolResponse {
|
||||
|
||||
@@ -86,9 +86,20 @@ impl ExternalAuthRefresher for ExternalAuthRefreshBridge {
|
||||
.await;
|
||||
|
||||
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
|
||||
Ok(result) => result.map_err(|err| {
|
||||
std::io::Error::other(format!("auth refresh request canceled: {err}"))
|
||||
})?,
|
||||
Ok(result) => {
|
||||
// Two failure scenarios:
|
||||
// 1) `oneshot::Receiver` failed (sender dropped) => request canceled/channel closed.
|
||||
// 2) client answered with JSON-RPC error payload => propagate code/message.
|
||||
let result = result.map_err(|err| {
|
||||
std::io::Error::other(format!("auth refresh request canceled: {err}"))
|
||||
})?;
|
||||
result.map_err(|err| {
|
||||
std::io::Error::other(format!(
|
||||
"auth refresh request failed: code={} message={}",
|
||||
err.code, err.message
|
||||
))
|
||||
})?
|
||||
}
|
||||
Err(_) => {
|
||||
let _canceled = self.outgoing.cancel_request(&request_id).await;
|
||||
return Err(std::io::Error::other(format!(
|
||||
|
||||
@@ -20,6 +20,8 @@ use crate::error_code::INTERNAL_ERROR_CODE;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::account::PlanType;
|
||||
|
||||
pub(crate) type ClientRequestResult = std::result::Result<Result, JSONRPCErrorError>;
|
||||
|
||||
/// Stable identifier for a transport connection.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) struct ConnectionId(pub(crate) u64);
|
||||
@@ -46,7 +48,7 @@ pub(crate) enum OutgoingEnvelope {
|
||||
pub(crate) struct OutgoingMessageSender {
|
||||
next_server_request_id: AtomicI64,
|
||||
sender: mpsc::Sender<OutgoingEnvelope>,
|
||||
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
|
||||
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<ClientRequestResult>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -69,7 +71,7 @@ impl ThreadScopedOutgoingMessageSender {
|
||||
pub(crate) async fn send_request(
|
||||
&self,
|
||||
payload: ServerRequestPayload,
|
||||
) -> oneshot::Receiver<Result> {
|
||||
) -> oneshot::Receiver<ClientRequestResult> {
|
||||
if self.connection_ids.is_empty() {
|
||||
let (_tx, rx) = oneshot::channel();
|
||||
return rx;
|
||||
@@ -118,7 +120,7 @@ impl OutgoingMessageSender {
|
||||
&self,
|
||||
connection_ids: &[ConnectionId],
|
||||
request: ServerRequestPayload,
|
||||
) -> oneshot::Receiver<Result> {
|
||||
) -> oneshot::Receiver<ClientRequestResult> {
|
||||
let (_id, rx) = self
|
||||
.send_request_with_id_to_connections(connection_ids, request)
|
||||
.await;
|
||||
@@ -128,7 +130,7 @@ impl OutgoingMessageSender {
|
||||
pub(crate) async fn send_request_with_id(
|
||||
&self,
|
||||
request: ServerRequestPayload,
|
||||
) -> (RequestId, oneshot::Receiver<Result>) {
|
||||
) -> (RequestId, oneshot::Receiver<ClientRequestResult>) {
|
||||
self.send_request_with_id_to_connections(&[], request).await
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ impl OutgoingMessageSender {
|
||||
&self,
|
||||
connection_ids: &[ConnectionId],
|
||||
request: ServerRequestPayload,
|
||||
) -> (RequestId, oneshot::Receiver<Result>) {
|
||||
) -> (RequestId, oneshot::Receiver<ClientRequestResult>) {
|
||||
let id = RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed));
|
||||
let outgoing_message_id = id.clone();
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
@@ -190,7 +192,7 @@ impl OutgoingMessageSender {
|
||||
|
||||
match entry {
|
||||
Some((id, sender)) => {
|
||||
if let Err(err) = sender.send(result) {
|
||||
if let Err(err) = sender.send(Ok(result)) {
|
||||
warn!("could not notify callback for {id:?} due to: {err:?}");
|
||||
}
|
||||
}
|
||||
@@ -207,8 +209,11 @@ impl OutgoingMessageSender {
|
||||
};
|
||||
|
||||
match entry {
|
||||
Some((id, _sender)) => {
|
||||
Some((id, sender)) => {
|
||||
warn!("client responded with error for {id:?}: {error:?}");
|
||||
if let Err(err) = sender.send(Err(error)) {
|
||||
warn!("could not notify callback for {id:?} due to: {err:?}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warn!("could not find callback for {id:?}");
|
||||
@@ -390,11 +395,13 @@ mod tests {
|
||||
use codex_app_server_protocol::AccountLoginCompletedNotification;
|
||||
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::ApplyPatchApprovalParams;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tokio::time::timeout;
|
||||
@@ -609,4 +616,38 @@ mod tests {
|
||||
other => panic!("expected targeted error envelope, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_client_error_forwards_error_to_waiter() {
|
||||
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4);
|
||||
let outgoing = OutgoingMessageSender::new(tx);
|
||||
|
||||
let (request_id, wait_for_result) = outgoing
|
||||
.send_request_with_id(ServerRequestPayload::ApplyPatchApproval(
|
||||
ApplyPatchApprovalParams {
|
||||
conversation_id: ThreadId::new(),
|
||||
call_id: "call-id".to_string(),
|
||||
file_changes: HashMap::new(),
|
||||
reason: None,
|
||||
grant_root: None,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: "refresh failed".to_string(),
|
||||
data: None,
|
||||
};
|
||||
|
||||
outgoing
|
||||
.notify_client_error(request_id, error.clone())
|
||||
.await;
|
||||
|
||||
let result = timeout(Duration::from_secs(1), wait_for_result)
|
||||
.await
|
||||
.expect("wait should not time out")
|
||||
.expect("waiter should receive a callback");
|
||||
assert_eq!(result, Err(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +564,7 @@ fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std
|
||||
cwd: PathBuf::from("/"),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
network: None,
|
||||
model: model.to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
@@ -86,6 +86,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
@@ -173,6 +174,78 @@ connectors = false
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
|
||||
let connectors = vec![AppInfo {
|
||||
id: "beta".to_string(),
|
||||
name: "Beta".to_string(),
|
||||
description: Some("Beta connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
chatgpt_base_url = "{server_url}"
|
||||
|
||||
[features]
|
||||
connectors = true
|
||||
|
||||
[apps.beta]
|
||||
enabled = false
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_apps_list_request(AppsListParams {
|
||||
limit: None,
|
||||
cursor: None,
|
||||
thread_id: None,
|
||||
force_refetch: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let AppsListResponse {
|
||||
data: response_data,
|
||||
next_cursor,
|
||||
} = to_response(response)?;
|
||||
assert!(next_cursor.is_none());
|
||||
assert_eq!(response_data.len(), 1);
|
||||
assert_eq!(response_data[0].id, "beta");
|
||||
assert!(!response_data[0].is_enabled);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> {
|
||||
let connectors = vec![
|
||||
@@ -185,6 +258,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -195,6 +269,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -239,6 +314,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}];
|
||||
|
||||
let first_update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
@@ -254,6 +330,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -264,6 +341,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -300,6 +378,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -310,6 +389,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -358,6 +438,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -368,6 +449,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -382,6 +464,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
@@ -392,6 +475,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -423,6 +507,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
@@ -433,6 +518,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -486,6 +572,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}];
|
||||
|
||||
assert_eq!(first_page, expected_first);
|
||||
@@ -525,6 +612,7 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
|
||||
assert_eq!(second_page, expected_second);
|
||||
@@ -545,6 +633,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}];
|
||||
let tools = vec![connector_tool("beta", "Beta App")?];
|
||||
let (server_url, server_handle) =
|
||||
|
||||
@@ -255,6 +255,7 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "windows", ignore = "flaky on windows CI")]
|
||||
#[tokio::test]
|
||||
async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<()> {
|
||||
let review_payload = json!({
|
||||
@@ -437,6 +438,7 @@ model_provider = "mock_provider"
|
||||
|
||||
[features]
|
||||
remote_models = false
|
||||
shell_snapshot = false
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider"
|
||||
|
||||
@@ -17,6 +17,8 @@ use codex_app_server_protocol::ThreadSourceKind;
|
||||
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::GitInfo as CoreGitInfo;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionSource as CoreSessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -66,6 +68,7 @@ async fn list_threads_with_sort(
|
||||
model_providers: providers,
|
||||
source_kinds,
|
||||
archived,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
@@ -127,6 +130,26 @@ fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_rollout_cwd(path: &Path, cwd: &Path) -> Result<()> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
|
||||
let first_line = lines
|
||||
.first_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("rollout at {} is empty", path.display()))?;
|
||||
let mut rollout_line: RolloutLine = serde_json::from_str(first_line)?;
|
||||
let RolloutItem::SessionMeta(mut session_meta_line) = rollout_line.item else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"rollout at {} does not start with session metadata",
|
||||
path.display()
|
||||
));
|
||||
};
|
||||
session_meta_line.meta.cwd = cwd.to_path_buf();
|
||||
rollout_line.item = RolloutItem::SessionMeta(session_meta_line);
|
||||
*first_line = serde_json::to_string(&rollout_line)?;
|
||||
fs::write(path, lines.join("\n") + "\n")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_basic_empty() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -300,6 +323,63 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_respects_cwd_filter() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_minimal_config(codex_home.path())?;
|
||||
|
||||
let filtered_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-02T10-00-00",
|
||||
"2025-01-02T10:00:00Z",
|
||||
"filtered",
|
||||
Some("mock_provider"),
|
||||
None,
|
||||
)?;
|
||||
let unfiltered_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-02T11-00-00",
|
||||
"2025-01-02T11:00:00Z",
|
||||
"unfiltered",
|
||||
Some("mock_provider"),
|
||||
None,
|
||||
)?;
|
||||
|
||||
let target_cwd = codex_home.path().join("target-cwd");
|
||||
fs::create_dir_all(&target_cwd)?;
|
||||
set_rollout_cwd(
|
||||
rollout_path(codex_home.path(), "2025-01-02T10-00-00", &filtered_id).as_path(),
|
||||
&target_cwd,
|
||||
)?;
|
||||
|
||||
let mut mcp = init_mcp(codex_home.path()).await?;
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
model_providers: Some(vec!["mock_provider".to_string()]),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: Some(target_cwd.to_string_lossy().into_owned()),
|
||||
})
|
||||
.await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadListResponse { data, next_cursor } = to_response::<ThreadListResponse>(resp)?;
|
||||
|
||||
assert_eq!(next_cursor, None);
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].id, filtered_id);
|
||||
assert_ne!(data[0].id, unfiltered_id);
|
||||
assert_eq!(data[0].cwd, target_cwd);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1107,6 +1187,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
|
||||
model_providers: Some(vec!["mock_provider".to_string()]),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
let error: JSONRPCError = timeout(
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_core::connectors::connector_install_url;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
|
||||
use codex_core::connectors::merge_connectors;
|
||||
pub use codex_core::connectors::with_app_enabled_state;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DirectoryListResponse {
|
||||
@@ -72,7 +73,10 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
||||
);
|
||||
let connectors = connectors_result?;
|
||||
let accessible = accessible_result?;
|
||||
Ok(merge_connectors_with_accessible(connectors, accessible))
|
||||
Ok(with_app_enabled_state(
|
||||
merge_connectors_with_accessible(connectors, accessible),
|
||||
config,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
||||
@@ -283,6 +287,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
|
||||
distribution_channel: app.distribution_channel,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +346,7 @@ mod tests {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ async fn run_command_under_sandbox(
|
||||
let sandbox_policy_cwd = cwd.clone();
|
||||
|
||||
let stdio_policy = StdioPolicy::Inherit;
|
||||
let env = create_env(&config.shell_environment_policy, None);
|
||||
let env = create_env(&config.permissions.shell_environment_policy, None);
|
||||
|
||||
// Special-case Windows sandbox: execute and exit the process to emulate inherited stdio.
|
||||
if let SandboxType::Windows = sandbox_type {
|
||||
@@ -141,7 +141,7 @@ async fn run_command_under_sandbox(
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
|
||||
|
||||
let policy_str = serde_json::to_string(config.sandbox_policy.get())?;
|
||||
let policy_str = serde_json::to_string(config.permissions.sandbox_policy.get())?;
|
||||
|
||||
let sandbox_cwd = sandbox_policy_cwd.clone();
|
||||
let cwd_clone = cwd.clone();
|
||||
@@ -214,7 +214,7 @@ async fn run_command_under_sandbox(
|
||||
let _ = log_denials;
|
||||
|
||||
// This proxy should only live for the lifetime of the child process.
|
||||
let network_proxy = match config.network.as_ref() {
|
||||
let network_proxy = match config.permissions.network.as_ref() {
|
||||
Some(spec) => Some(
|
||||
spec.start_proxy()
|
||||
.await
|
||||
@@ -232,7 +232,7 @@ async fn run_command_under_sandbox(
|
||||
spawn_command_under_seatbelt(
|
||||
command,
|
||||
cwd,
|
||||
config.sandbox_policy.get(),
|
||||
config.permissions.sandbox_policy.get(),
|
||||
sandbox_policy_cwd.as_path(),
|
||||
stdio_policy,
|
||||
network.as_ref(),
|
||||
@@ -251,7 +251,7 @@ async fn run_command_under_sandbox(
|
||||
codex_linux_sandbox_exe,
|
||||
command,
|
||||
cwd,
|
||||
config.sandbox_policy.get(),
|
||||
config.permissions.sandbox_policy.get(),
|
||||
sandbox_policy_cwd.as_path(),
|
||||
use_bwrap_sandbox,
|
||||
stdio_policy,
|
||||
|
||||
@@ -14,6 +14,30 @@ When using the workspace-write sandbox policy, the Seatbelt profile allows
|
||||
writes under the configured writable roots while keeping `.git` (directory or
|
||||
pointer file), the resolved `gitdir:` target, and `.codex` read-only.
|
||||
|
||||
Network access and filesystem read/write roots are controlled by
|
||||
`SandboxPolicy`. Seatbelt consumes the resolved policy and enforces it.
|
||||
|
||||
Seatbelt also supports macOS permission-profile extensions layered on top of
|
||||
`SandboxPolicy`:
|
||||
|
||||
- no extension profile provided:
|
||||
keeps legacy default preferences read access (`user-preference-read`).
|
||||
- extension profile provided with no `macos_preferences` grant:
|
||||
does not add preferences access clauses.
|
||||
- `macos_preferences = "readonly"`:
|
||||
enables cfprefs read clauses and `user-preference-read`.
|
||||
- `macos_preferences = "readwrite"`:
|
||||
includes readonly clauses plus `user-preference-write` and cfprefs shm write
|
||||
clauses.
|
||||
- `macos_automation = true`:
|
||||
enables broad Apple Events send permissions.
|
||||
- `macos_automation = ["com.apple.Notes", ...]`:
|
||||
enables Apple Events send only to listed bundle IDs.
|
||||
- `macos_accessibility = true`:
|
||||
enables `com.apple.axserver` mach lookup.
|
||||
- `macos_calendar = true`:
|
||||
enables `com.apple.CalendarAgent` mach lookup.
|
||||
|
||||
### Linux
|
||||
|
||||
Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details.
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
|
||||
"description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
|
||||
"enum": [
|
||||
"on-failure"
|
||||
],
|
||||
@@ -188,6 +188,9 @@
|
||||
"apps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apps_mcp_gateway": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -224,6 +227,9 @@
|
||||
"js_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_tools_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memory_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1272,6 +1278,9 @@
|
||||
"apps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apps_mcp_gateway": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1308,6 +1317,9 @@
|
||||
"js_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_tools_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memory_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -122,6 +122,7 @@ Rules:
|
||||
}
|
||||
if profile.read_only {
|
||||
config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::new_read_only_policy())
|
||||
.map_err(|err| format!("sandbox_policy is invalid: {err}"))?;
|
||||
|
||||
@@ -81,7 +81,6 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
resets_at,
|
||||
rate_limits: rate_limits.map(Box::new),
|
||||
promo_message,
|
||||
limit_name: limit_id,
|
||||
});
|
||||
} else if err.error.error_type.as_deref() == Some("usage_not_included") {
|
||||
return CodexErr::UsageNotIncluded;
|
||||
@@ -156,6 +155,10 @@ mod tests {
|
||||
ACTIVE_LIMIT_HEADER,
|
||||
http::HeaderValue::from_static("codex_other"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-other-limit-name",
|
||||
http::HeaderValue::from_static("codex_other"),
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"type": "usage_limit_reached",
|
||||
@@ -173,7 +176,46 @@ mod tests {
|
||||
let CodexErr::UsageLimitReached(usage_limit) = err else {
|
||||
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
|
||||
};
|
||||
assert_eq!(usage_limit.limit_name.as_deref(), Some("codex_other"));
|
||||
assert_eq!(
|
||||
usage_limit
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.limit_name.as_deref()),
|
||||
Some("codex_other")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
ACTIVE_LIMIT_HEADER,
|
||||
http::HeaderValue::from_static("codex_other"),
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"type": "usage_limit_reached",
|
||||
"plan_type": "pro",
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::TOO_MANY_REQUESTS,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some(body),
|
||||
}));
|
||||
|
||||
let CodexErr::UsageLimitReached(usage_limit) = err else {
|
||||
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
usage_limit
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.limit_name.as_deref()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+247
-34
@@ -73,6 +73,7 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnContextNetworkItem;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
@@ -114,6 +115,7 @@ use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::compact::collect_user_messages;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
@@ -246,6 +248,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
|
||||
@@ -389,8 +392,8 @@ impl Codex {
|
||||
personality: config.personality,
|
||||
base_instructions,
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
@@ -653,6 +656,41 @@ impl TurnContext {
|
||||
.unwrap_or(compact::SUMMARIZATION_PROMPT)
|
||||
}
|
||||
|
||||
pub(crate) fn to_turn_context_item(
|
||||
&self,
|
||||
collaboration_mode: CollaborationMode,
|
||||
) -> TurnContextItem {
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
cwd: self.cwd.clone(),
|
||||
approval_policy: self.approval_policy,
|
||||
sandbox_policy: self.sandbox_policy.clone(),
|
||||
network: self.turn_context_network_item(),
|
||||
model: self.model_info.slug.clone(),
|
||||
personality: self.personality,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
effort: self.reasoning_effort,
|
||||
summary: self.reasoning_summary,
|
||||
user_instructions: self.user_instructions.clone(),
|
||||
developer_instructions: self.developer_instructions.clone(),
|
||||
final_output_json_schema: self.final_output_json_schema.clone(),
|
||||
truncation_policy: Some(self.truncation_policy.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_context_network_item(&self) -> Option<TurnContextNetworkItem> {
|
||||
let network = self
|
||||
.config
|
||||
.config_layer_stack
|
||||
.requirements()
|
||||
.network
|
||||
.as_ref()?;
|
||||
Some(TurnContextNetworkItem {
|
||||
allowed_domains: network.allowed_domains.clone().unwrap_or_default(),
|
||||
denied_domains: network.denied_domains.clone().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_turn_metadata_header(&self) -> Option<String> {
|
||||
let sandbox = sandbox_tag(&self.sandbox_policy, self.windows_sandbox_level);
|
||||
self.turn_metadata_header
|
||||
@@ -932,7 +970,7 @@ impl Session {
|
||||
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
|
||||
network,
|
||||
windows_sandbox_level: session_configuration.windows_sandbox_level,
|
||||
shell_environment_policy: per_turn_config.shell_environment_policy.clone(),
|
||||
shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(),
|
||||
tools_config,
|
||||
features: per_turn_config.features.clone(),
|
||||
ghost_snapshot: per_turn_config.ghost_snapshot.clone(),
|
||||
@@ -1099,6 +1137,14 @@ impl Session {
|
||||
});
|
||||
}
|
||||
maybe_push_unstable_features_warning(&config, &mut post_session_configured_events);
|
||||
if config.permissions.approval_policy.value() == AskForApproval::OnFailure {
|
||||
post_session_configured_events.push(Event {
|
||||
id: "".to_owned(),
|
||||
msg: EventMsg::Warning(WarningEvent {
|
||||
message: "`on-failure` approval policy is deprecated and will be removed in a future release. Use `on-request` for interactive approvals or `never` for non-interactive runs.".to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let auth = auth.as_ref();
|
||||
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
|
||||
@@ -1134,8 +1180,8 @@ impl Session {
|
||||
config.model_reasoning_summary,
|
||||
config.model_context_window,
|
||||
config.model_auto_compact_token_limit,
|
||||
config.approval_policy.value(),
|
||||
config.sandbox_policy.get().clone(),
|
||||
config.permissions.approval_policy.value(),
|
||||
config.permissions.sandbox_policy.get().clone(),
|
||||
mcp_servers.keys().map(String::as_str).collect(),
|
||||
config.active_profile.clone(),
|
||||
);
|
||||
@@ -1167,7 +1213,7 @@ impl Session {
|
||||
session_configuration.thread_name = thread_name.clone();
|
||||
let mut state = SessionState::new(session_configuration.clone());
|
||||
let network_proxy =
|
||||
match config.network.as_ref() {
|
||||
match config.permissions.network.as_ref() {
|
||||
Some(spec) => Some(spec.start_proxy().await.map_err(|err| {
|
||||
anyhow::anyhow!("failed to start managed network proxy: {err}")
|
||||
})?),
|
||||
@@ -1689,7 +1735,7 @@ impl Session {
|
||||
|
||||
if sandbox_policy_changed {
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: per_turn_config.sandbox_policy.get().clone(),
|
||||
sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: per_turn_config.cwd.clone(),
|
||||
use_linux_sandbox_bwrap: per_turn_config
|
||||
@@ -1757,6 +1803,48 @@ impl Session {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn reload_user_config_layer(&self) {
|
||||
let config_toml_path = {
|
||||
let state = self.state.lock().await;
|
||||
state
|
||||
.session_configuration
|
||||
.codex_home
|
||||
.join(CONFIG_TOML_FILE)
|
||||
};
|
||||
|
||||
let user_config = match std::fs::read_to_string(&config_toml_path) {
|
||||
Ok(contents) => match toml::from_str::<toml::Value>(&contents) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
warn!("failed to parse user config while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
toml::Value::Table(Default::default())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to read user config while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let config_toml_path = match AbsolutePathBuf::try_from(config_toml_path) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
warn!("failed to resolve user config path while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut state = self.state.lock().await;
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
||||
}
|
||||
|
||||
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
|
||||
let session_configuration = {
|
||||
let state = self.state.lock().await;
|
||||
@@ -3042,6 +3130,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::RefreshMcpServers { config } => {
|
||||
handlers::refresh_mcp_servers(&sess, config).await;
|
||||
}
|
||||
Op::ReloadUserConfig => {
|
||||
handlers::reload_user_config(&sess).await;
|
||||
}
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
}
|
||||
@@ -3466,6 +3557,10 @@ mod handlers {
|
||||
*guard = Some(refresh_config);
|
||||
}
|
||||
|
||||
pub async fn reload_user_config(sess: &Arc<Session>) {
|
||||
sess.reload_user_config_layer().await;
|
||||
}
|
||||
|
||||
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
|
||||
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
@@ -4106,7 +4201,10 @@ pub(crate) async fn run_turn(
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) => return None,
|
||||
};
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools)
|
||||
connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn_context.config,
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -4456,6 +4554,14 @@ fn filter_connectors_for_input(
|
||||
explicitly_enabled_connectors: &HashSet<String>,
|
||||
skill_name_counts_lower: &HashMap<String, usize>,
|
||||
) -> Vec<connectors::AppInfo> {
|
||||
let connectors = connectors
|
||||
.into_iter()
|
||||
.filter(|connector| connector.is_enabled)
|
||||
.collect::<Vec<_>>();
|
||||
if connectors.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let user_messages = collect_user_messages(input);
|
||||
if user_messages.is_empty() && explicitly_enabled_connectors.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -4702,7 +4808,10 @@ async fn built_tools(
|
||||
let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| {
|
||||
build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1
|
||||
});
|
||||
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
|
||||
let connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn_context.config,
|
||||
);
|
||||
Some(filter_connectors_for_input(
|
||||
connectors,
|
||||
input,
|
||||
@@ -5163,21 +5272,8 @@ async fn try_run_sampling_request(
|
||||
cancellation_token: CancellationToken,
|
||||
) -> CodexResult<SamplingRequestResult> {
|
||||
let collaboration_mode = sess.current_collaboration_mode().await;
|
||||
let rollout_item = RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
approval_policy: turn_context.approval_policy,
|
||||
sandbox_policy: turn_context.sandbox_policy.clone(),
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: turn_context.user_instructions.clone(),
|
||||
developer_instructions: turn_context.developer_instructions.clone(),
|
||||
final_output_json_schema: turn_context.final_output_json_schema.clone(),
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
});
|
||||
let rollout_item =
|
||||
RolloutItem::TurnContext(turn_context.to_turn_context_item(collaboration_mode));
|
||||
|
||||
feedback_tags!(
|
||||
model = turn_context.model_info.slug.clone(),
|
||||
@@ -5477,6 +5573,11 @@ mod tests {
|
||||
use crate::CodexAuth;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::test_config;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::NetworkConstraints;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::config_loader::Sourced;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
@@ -5507,6 +5608,7 @@ mod tests {
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::router::ToolCallSource;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
@@ -5568,6 +5670,7 @@ mod tests {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5653,6 +5756,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_user_config_layer_updates_effective_apps_config() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
||||
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
std::fs::write(
|
||||
&config_toml_path,
|
||||
"[apps.calendar]\nenabled = false\ndisabled_reason = \"user\"\n",
|
||||
)
|
||||
.expect("write user config");
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
|
||||
let config = session.get_config().await;
|
||||
let apps_toml = config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("apps"))
|
||||
.cloned()
|
||||
.expect("apps table");
|
||||
let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml)
|
||||
.expect("deserialize apps config");
|
||||
let app = apps
|
||||
.apps
|
||||
.get("calendar")
|
||||
.expect("calendar app config exists");
|
||||
|
||||
assert!(!app.enabled);
|
||||
assert_eq!(
|
||||
app.disabled_reason,
|
||||
Some(crate::config::types::AppDisabledReason::User)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_connectors_for_input_skips_duplicate_slug_mentions() {
|
||||
let connectors = vec![
|
||||
@@ -5690,6 +5829,22 @@ mod tests {
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_connectors_for_input_skips_disabled_connectors() {
|
||||
let mut connector = make_connector("calendar", "Calendar");
|
||||
connector.is_enabled = false;
|
||||
let input = vec![user_message("use $calendar")];
|
||||
let explicitly_enabled_connectors = HashSet::new();
|
||||
let selected = filter_connectors_for_input(
|
||||
vec![connector],
|
||||
&input,
|
||||
&explicitly_enabled_connectors,
|
||||
&HashMap::new(),
|
||||
);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() {
|
||||
let connectors = vec![make_connector("calendar", "Calendar")];
|
||||
@@ -5897,6 +6052,7 @@ mod tests {
|
||||
cwd: turn_context.cwd.clone(),
|
||||
approval_policy: turn_context.approval_policy,
|
||||
sandbox_policy: turn_context.sandbox_policy.clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -6115,6 +6271,7 @@ mod tests {
|
||||
cwd: turn_context.cwd.clone(),
|
||||
approval_policy: turn_context.approval_policy,
|
||||
sandbox_policy: turn_context.sandbox_policy.clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -6304,8 +6461,8 @@ mod tests {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
@@ -6395,8 +6552,8 @@ mod tests {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
@@ -6705,8 +6862,8 @@ mod tests {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
@@ -6758,8 +6915,8 @@ mod tests {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
@@ -6904,8 +7061,8 @@ mod tests {
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.approval_policy.clone(),
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
@@ -7105,6 +7262,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_settings_update_items_emits_environment_item_for_network_changes() {
|
||||
let (session, previous_context) = make_session_and_context().await;
|
||||
let previous_context = Arc::new(previous_context);
|
||||
let mut current_context = previous_context
|
||||
.with_model(
|
||||
previous_context.model_info.slug.clone(),
|
||||
&session.services.models_manager,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut config = (*current_context.config).clone();
|
||||
let mut requirements = config.config_layer_stack.requirements().clone();
|
||||
requirements.network = Some(Sourced::new(
|
||||
NetworkConstraints {
|
||||
allowed_domains: Some(vec!["api.example.com".to_string()]),
|
||||
denied_domains: Some(vec!["blocked.example.com".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
RequirementSource::CloudRequirements,
|
||||
));
|
||||
let layers = config
|
||||
.config_layer_stack
|
||||
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
config.config_layer_stack = ConfigLayerStack::new(
|
||||
layers,
|
||||
requirements,
|
||||
config.config_layer_stack.requirements_toml().clone(),
|
||||
)
|
||||
.expect("rebuild config layer stack with network requirements");
|
||||
current_context.config = Arc::new(config);
|
||||
|
||||
let update_items =
|
||||
session.build_settings_update_items(Some(&previous_context), None, ¤t_context);
|
||||
|
||||
let environment_update = update_items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
text.contains("<environment_context>").then_some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("environment update item should be emitted");
|
||||
assert!(environment_update.contains("<network enabled=\"true\">"));
|
||||
assert!(environment_update.contains("<allowed>api.example.com</allowed>"));
|
||||
assert!(environment_update.contains("<denied>blocked.example.com</denied>"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NeverEndingTask {
|
||||
kind: TaskKind,
|
||||
@@ -7444,6 +7656,7 @@ mod tests {
|
||||
Arc::clone(&turn_context),
|
||||
tracker,
|
||||
call,
|
||||
ToolCallSource::Direct,
|
||||
)
|
||||
.await
|
||||
.expect_err("expected fatal error");
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnContextItem;
|
||||
use crate::protocol::TurnStartedEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
@@ -95,21 +94,8 @@ async fn run_compact_task_inner(
|
||||
// duplicating model settings on TurnContext, but an Op after turn start could update the
|
||||
// session config before this write occurs.
|
||||
let collaboration_mode = sess.current_collaboration_mode().await;
|
||||
let rollout_item = RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
approval_policy: turn_context.approval_policy,
|
||||
sandbox_policy: turn_context.sandbox_policy.clone(),
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
effort: turn_context.reasoning_effort,
|
||||
summary: turn_context.reasoning_summary,
|
||||
user_instructions: turn_context.user_instructions.clone(),
|
||||
developer_instructions: turn_context.developer_instructions.clone(),
|
||||
final_output_json_schema: turn_context.final_output_json_schema.clone(),
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
});
|
||||
let rollout_item =
|
||||
RolloutItem::TurnContext(turn_context.to_turn_context_item(collaboration_mode));
|
||||
sess.persist_rollout_items(&[rollout_item]).await;
|
||||
|
||||
loop {
|
||||
|
||||
@@ -117,6 +117,22 @@ pub(crate) fn test_config() -> Config {
|
||||
.expect("load default test config")
|
||||
}
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Permissions {
|
||||
/// Approval policy for executing commands.
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
/// Effective sandbox policy used for shell/unified exec.
|
||||
pub sandbox_policy: Constrained<SandboxPolicy>,
|
||||
/// Effective network configuration applied to all spawned processes.
|
||||
pub network: Option<NetworkProxySpec>,
|
||||
/// Policy used to build process environments for shell/unified exec.
|
||||
pub shell_environment_policy: ShellEnvironmentPolicy,
|
||||
/// Effective Windows sandbox mode derived from `[windows].sandbox` or
|
||||
/// legacy feature keys.
|
||||
pub windows_sandbox_mode: Option<WindowsSandboxModeToml>,
|
||||
}
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Config {
|
||||
@@ -148,25 +164,18 @@ pub struct Config {
|
||||
/// Optionally specify the personality of the model
|
||||
pub personality: Option<Personality>,
|
||||
|
||||
/// Approval policy for executing commands.
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
|
||||
pub sandbox_policy: Constrained<SandboxPolicy>,
|
||||
/// Effective permission configuration for shell tool execution.
|
||||
pub permissions: Permissions,
|
||||
|
||||
/// enforce_residency means web traffic cannot be routed outside of a
|
||||
/// particular geography. HTTP clients should direct their requests
|
||||
/// using backend-specific headers or URLs to enforce this.
|
||||
pub enforce_residency: Constrained<Option<ResidencyRequirement>>,
|
||||
|
||||
/// Effective network configuration applied to all spawned processes.
|
||||
pub network: Option<NetworkProxySpec>,
|
||||
|
||||
/// True if the user passed in an override or set a value in config.toml
|
||||
/// for either of approval_policy or sandbox_mode.
|
||||
pub did_user_set_custom_approval_policy_or_sandbox_mode: bool,
|
||||
|
||||
pub shell_environment_policy: ShellEnvironmentPolicy,
|
||||
|
||||
/// When `true`, `AgentReasoning` events emitted by the backend will be
|
||||
/// suppressed from the frontend output. This can reduce visual noise when
|
||||
/// users are only interested in the final agent responses.
|
||||
@@ -345,10 +354,6 @@ pub struct Config {
|
||||
/// Settings for ghost snapshots (used for undo).
|
||||
pub ghost_snapshot: GhostSnapshotConfig,
|
||||
|
||||
/// Effective Windows sandbox mode derived from `[windows].sandbox` or
|
||||
/// legacy feature keys.
|
||||
pub windows_sandbox_mode: Option<WindowsSandboxModeToml>,
|
||||
|
||||
/// Centralized feature flags; source of truth for feature gating.
|
||||
pub features: Features,
|
||||
|
||||
@@ -1726,12 +1731,15 @@ impl Config {
|
||||
model_provider,
|
||||
cwd: resolved_cwd,
|
||||
startup_warnings,
|
||||
approval_policy: constrained_approval_policy.value,
|
||||
sandbox_policy: constrained_sandbox_policy.value,
|
||||
permissions: Permissions {
|
||||
approval_policy: constrained_approval_policy.value,
|
||||
sandbox_policy: constrained_sandbox_policy.value,
|
||||
network,
|
||||
shell_environment_policy,
|
||||
windows_sandbox_mode,
|
||||
},
|
||||
enforce_residency: enforce_residency.value,
|
||||
network,
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode,
|
||||
shell_environment_policy,
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
@@ -1796,7 +1804,6 @@ impl Config {
|
||||
web_search_mode: constrained_web_search_mode.value,
|
||||
use_experimental_unified_exec_tool,
|
||||
ghost_snapshot,
|
||||
windows_sandbox_mode,
|
||||
features,
|
||||
suppress_unstable_features_warning: cfg
|
||||
.suppress_unstable_features_warning
|
||||
@@ -1902,28 +1909,28 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn set_windows_sandbox_enabled(&mut self, value: bool) {
|
||||
self.windows_sandbox_mode = if value {
|
||||
self.permissions.windows_sandbox_mode = if value {
|
||||
Some(WindowsSandboxModeToml::Unelevated)
|
||||
} else if matches!(
|
||||
self.windows_sandbox_mode,
|
||||
self.permissions.windows_sandbox_mode,
|
||||
Some(WindowsSandboxModeToml::Unelevated)
|
||||
) {
|
||||
None
|
||||
} else {
|
||||
self.windows_sandbox_mode
|
||||
self.permissions.windows_sandbox_mode
|
||||
};
|
||||
}
|
||||
|
||||
pub fn set_windows_elevated_sandbox_enabled(&mut self, value: bool) {
|
||||
self.windows_sandbox_mode = if value {
|
||||
self.permissions.windows_sandbox_mode = if value {
|
||||
Some(WindowsSandboxModeToml::Elevated)
|
||||
} else if matches!(
|
||||
self.windows_sandbox_mode,
|
||||
self.permissions.windows_sandbox_mode,
|
||||
Some(WindowsSandboxModeToml::Elevated)
|
||||
) {
|
||||
None
|
||||
} else {
|
||||
self.windows_sandbox_mode
|
||||
self.permissions.windows_sandbox_mode
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2366,12 +2373,12 @@ trust_level = "trusted"
|
||||
|
||||
let expected_backend = AbsolutePathBuf::try_from(backend).unwrap();
|
||||
if cfg!(target_os = "windows") {
|
||||
match config.sandbox_policy.get() {
|
||||
match config.permissions.sandbox_policy.get() {
|
||||
SandboxPolicy::ReadOnly { .. } => {}
|
||||
other => panic!("expected read-only policy on Windows, got {other:?}"),
|
||||
}
|
||||
} else {
|
||||
match config.sandbox_policy.get() {
|
||||
match config.permissions.sandbox_policy.get() {
|
||||
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
||||
assert_eq!(
|
||||
writable_roots
|
||||
@@ -2660,7 +2667,7 @@ profile = "project"
|
||||
)?;
|
||||
|
||||
assert!(matches!(
|
||||
config.sandbox_policy.get(),
|
||||
config.permissions.sandbox_policy.get(),
|
||||
&SandboxPolicy::DangerFullAccess
|
||||
));
|
||||
assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode);
|
||||
@@ -2698,12 +2705,12 @@ profile = "project"
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(matches!(
|
||||
config.sandbox_policy.get(),
|
||||
config.permissions.sandbox_policy.get(),
|
||||
SandboxPolicy::ReadOnly { .. }
|
||||
));
|
||||
} else {
|
||||
assert!(matches!(
|
||||
config.sandbox_policy.get(),
|
||||
config.permissions.sandbox_policy.get(),
|
||||
SandboxPolicy::WorkspaceWrite { .. }
|
||||
));
|
||||
}
|
||||
@@ -4019,12 +4026,15 @@ model_verbosity = "high"
|
||||
model_auto_compact_token_limit: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
permissions: Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
network: None,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
},
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
network: None,
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -4067,7 +4077,6 @@ model_verbosity = "high"
|
||||
suppress_unstable_features_warning: false,
|
||||
active_profile: Some("o3".to_string()),
|
||||
active_project: ProjectConfig { trust_level: None },
|
||||
windows_sandbox_mode: None,
|
||||
windows_wsl_setup_acknowledged: false,
|
||||
notices: Default::default(),
|
||||
check_for_update_on_startup: true,
|
||||
@@ -4126,12 +4135,15 @@ model_verbosity = "high"
|
||||
model_auto_compact_token_limit: None,
|
||||
model_provider_id: "openai-custom".to_string(),
|
||||
model_provider: fixture.openai_custom_provider.clone(),
|
||||
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
permissions: Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
network: None,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
},
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
network: None,
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -4174,7 +4186,6 @@ model_verbosity = "high"
|
||||
suppress_unstable_features_warning: false,
|
||||
active_profile: Some("gpt3".to_string()),
|
||||
active_project: ProjectConfig { trust_level: None },
|
||||
windows_sandbox_mode: None,
|
||||
windows_wsl_setup_acknowledged: false,
|
||||
notices: Default::default(),
|
||||
check_for_update_on_startup: true,
|
||||
@@ -4231,12 +4242,15 @@ model_verbosity = "high"
|
||||
model_auto_compact_token_limit: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
permissions: Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
network: None,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
},
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
network: None,
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -4279,7 +4293,6 @@ model_verbosity = "high"
|
||||
suppress_unstable_features_warning: false,
|
||||
active_profile: Some("zdr".to_string()),
|
||||
active_project: ProjectConfig { trust_level: None },
|
||||
windows_sandbox_mode: None,
|
||||
windows_wsl_setup_acknowledged: false,
|
||||
notices: Default::default(),
|
||||
check_for_update_on_startup: true,
|
||||
@@ -4322,12 +4335,15 @@ model_verbosity = "high"
|
||||
model_auto_compact_token_limit: None,
|
||||
model_provider_id: "openai".to_string(),
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
permissions: Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
network: None,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
},
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
network: None,
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
cwd: fixture.cwd(),
|
||||
@@ -4370,7 +4386,6 @@ model_verbosity = "high"
|
||||
suppress_unstable_features_warning: false,
|
||||
active_profile: Some("gpt5".to_string()),
|
||||
active_project: ProjectConfig { trust_level: None },
|
||||
windows_sandbox_mode: None,
|
||||
windows_wsl_setup_acknowledged: false,
|
||||
notices: Default::default(),
|
||||
check_for_update_on_startup: true,
|
||||
@@ -4903,7 +4918,7 @@ mcp_oauth_callback_port = 5678
|
||||
|
||||
// Verify that untrusted projects get UnlessTrusted approval policy
|
||||
assert_eq!(
|
||||
config.approval_policy.value(),
|
||||
config.permissions.approval_policy.value(),
|
||||
AskForApproval::UnlessTrusted,
|
||||
"Expected UnlessTrusted approval policy for untrusted project"
|
||||
);
|
||||
@@ -4911,13 +4926,16 @@ mcp_oauth_callback_port = 5678
|
||||
// Verify that untrusted projects still get WorkspaceWrite sandbox (or ReadOnly on Windows)
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
matches!(config.sandbox_policy.get(), SandboxPolicy::ReadOnly { .. }),
|
||||
matches!(
|
||||
config.permissions.sandbox_policy.get(),
|
||||
SandboxPolicy::ReadOnly { .. }
|
||||
),
|
||||
"Expected ReadOnly on Windows"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(
|
||||
config.sandbox_policy.get(),
|
||||
config.permissions.sandbox_policy.get(),
|
||||
SandboxPolicy::WorkspaceWrite { .. }
|
||||
),
|
||||
"Expected WorkspaceWrite sandbox for untrusted project"
|
||||
@@ -4944,9 +4962,8 @@ mcp_oauth_callback_port = 5678
|
||||
}))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
*config.sandbox_policy.get(),
|
||||
*config.permissions.sandbox_policy.get(),
|
||||
SandboxPolicy::new_read_only_policy()
|
||||
);
|
||||
Ok(())
|
||||
@@ -4983,7 +5000,7 @@ mcp_oauth_callback_port = 5678
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
*config.sandbox_policy.get(),
|
||||
*config.permissions.sandbox_policy.get(),
|
||||
SandboxPolicy::new_read_only_policy()
|
||||
);
|
||||
Ok(())
|
||||
@@ -5015,7 +5032,10 @@ mcp_oauth_callback_port = 5678
|
||||
|
||||
assert_eq!(config.web_search_mode.value(), WebSearchMode::Cached);
|
||||
assert_eq!(
|
||||
resolve_web_search_mode_for_turn(&config.web_search_mode, config.sandbox_policy.get()),
|
||||
resolve_web_search_mode_for_turn(
|
||||
&config.web_search_mode,
|
||||
config.permissions.sandbox_policy.get(),
|
||||
),
|
||||
WebSearchMode::Cached,
|
||||
);
|
||||
Ok(())
|
||||
@@ -5049,7 +5069,10 @@ trust_level = "untrusted"
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.approval_policy.value(), AskForApproval::OnRequest);
|
||||
assert_eq!(
|
||||
config.permissions.approval_policy.value(),
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5074,7 +5097,10 @@ trust_level = "untrusted"
|
||||
}))
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(config.approval_policy.value(), AskForApproval::OnRequest);
|
||||
assert_eq!(
|
||||
config.permissions.approval_policy.value(),
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::time::Instant;
|
||||
use async_channel::unbounded;
|
||||
pub use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use serde::Deserialize;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -16,6 +17,7 @@ use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::SandboxState;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
@@ -265,6 +267,22 @@ pub fn merge_connectors(
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
|
||||
let apps = read_apps_config(config).map(|apps_config| apps_config.apps);
|
||||
for connector in &mut connectors {
|
||||
if let Some(app) = apps.as_ref().and_then(|apps| apps.get(&connector.id)) {
|
||||
connector.is_enabled = app.enabled;
|
||||
}
|
||||
}
|
||||
connectors
|
||||
}
|
||||
|
||||
fn read_apps_config(config: &Config) -> Option<AppsConfigToml> {
|
||||
let effective_config = config.config_layer_stack.effective_config();
|
||||
let apps_config = effective_config.as_table()?.get("apps")?.clone();
|
||||
AppsConfigToml::deserialize(apps_config).ok()
|
||||
}
|
||||
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
@@ -291,6 +309,7 @@ where
|
||||
distribution_channel: None,
|
||||
install_url: Some(connector_install_url(&connector_name, &connector_id)),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
})
|
||||
.collect();
|
||||
accessible.sort_by(|left, right| {
|
||||
|
||||
+14
-20
@@ -411,18 +411,22 @@ pub struct UsageLimitReachedError {
|
||||
pub(crate) resets_at: Option<DateTime<Utc>>,
|
||||
pub(crate) rate_limits: Option<Box<RateLimitSnapshot>>,
|
||||
pub(crate) promo_message: Option<String>,
|
||||
pub(crate) limit_name: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UsageLimitReachedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(limit_name) = self.limit_name.as_deref()
|
||||
if let Some(limit_name) = self
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.limit_name.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
&& !limit_name.eq_ignore_ascii_case("codex")
|
||||
{
|
||||
return write!(
|
||||
f,
|
||||
"You've hit your usage limit for {limit_name}.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
"You've hit your usage limit for {limit_name}. Switch to another model now,{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -704,7 +708,6 @@ mod tests {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -822,7 +825,6 @@ mod tests {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -837,7 +839,6 @@ mod tests {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -852,7 +853,6 @@ mod tests {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -871,7 +871,6 @@ mod tests {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
|
||||
@@ -887,7 +886,6 @@ mod tests {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -902,7 +900,6 @@ mod tests {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -921,7 +918,6 @@ mod tests {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
@@ -939,15 +935,18 @@ mod tests {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
rate_limits: Some(Box::new(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
..rate_limit_snapshot()
|
||||
})),
|
||||
promo_message: Some(
|
||||
"Visit https://chatgpt.com/codex/settings/usage to purchase more credits"
|
||||
.to_string(),
|
||||
),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit for codex_other. Try again at {expected_time}."
|
||||
"You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
@@ -964,7 +963,6 @@ mod tests {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
@@ -1074,7 +1072,6 @@ mod tests {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
@@ -1095,7 +1092,6 @@ mod tests {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
@@ -1113,7 +1109,6 @@ mod tests {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
@@ -1133,7 +1128,6 @@ mod tests {
|
||||
promo_message: Some(
|
||||
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
|
||||
),
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
|
||||
|
||||
@@ -1385,8 +1385,8 @@ prefix_rule(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangerous_git_push_requires_approval_in_danger_full_access() {
|
||||
let command = vec_str(&["git", "push", "origin", "+main"]);
|
||||
async fn dangerous_rm_rf_requires_approval_in_danger_full_access() {
|
||||
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
|
||||
let manager = ExecPolicyManager::default();
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
|
||||
@@ -80,6 +80,8 @@ pub enum Feature {
|
||||
// Experimental
|
||||
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
|
||||
JsRepl,
|
||||
/// Only expose js_repl tools directly to the model.
|
||||
JsReplToolsOnly,
|
||||
/// Use the single unified PTY-backed exec tool.
|
||||
UnifiedExec,
|
||||
/// Include the freeform apply_patch tool.
|
||||
@@ -119,6 +121,8 @@ pub enum Feature {
|
||||
Collab,
|
||||
/// Enable apps.
|
||||
Apps,
|
||||
/// Route apps MCP calls through the configured gateway.
|
||||
AppsMcpGateway,
|
||||
/// Allow prompting and installing missing MCP dependencies.
|
||||
SkillMcpDependencyInstall,
|
||||
/// Prompt for missing skill env var dependencies.
|
||||
@@ -322,6 +326,10 @@ impl Features {
|
||||
}
|
||||
|
||||
overrides.apply(&mut features);
|
||||
if features.enabled(Feature::JsReplToolsOnly) && !features.enabled(Feature::JsRepl) {
|
||||
tracing::warn!("js_repl_tools_only requires js_repl; disabling js_repl_tools_only");
|
||||
features.disable(Feature::JsReplToolsOnly);
|
||||
}
|
||||
|
||||
features
|
||||
}
|
||||
@@ -430,6 +438,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::JsReplToolsOnly,
|
||||
key: "js_repl_tools_only",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::WebSearchRequest,
|
||||
key: "web_search_request",
|
||||
@@ -554,6 +568,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::AppsMcpGateway,
|
||||
key: "apps_mcp_gateway",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::SkillMcpDependencyInstall,
|
||||
key: "skill_mcp_dependency_install",
|
||||
@@ -668,6 +688,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_enabled_features_are_stable() {
|
||||
for spec in FEATURES {
|
||||
if spec.default_enabled {
|
||||
assert_eq!(
|
||||
spec.stage,
|
||||
Stage::Stable,
|
||||
"feature `{}` is enabled by default but is not stable ({:?})",
|
||||
spec.key,
|
||||
spec.stage
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn use_linux_sandbox_bwrap_is_experimental_on_linux() {
|
||||
|
||||
@@ -77,8 +77,10 @@ pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
mod event_mapping;
|
||||
pub mod review_format;
|
||||
pub mod review_prompts;
|
||||
mod seatbelt_permissions;
|
||||
mod thread_manager;
|
||||
pub mod web_search;
|
||||
pub mod windows_sandbox_read_grants;
|
||||
pub use codex_protocol::protocol::InitialHistory;
|
||||
pub use thread_manager::NewThread;
|
||||
pub use thread_manager::ThreadManager;
|
||||
|
||||
@@ -30,6 +30,15 @@ const MCP_TOOL_NAME_PREFIX: &str = "mcp";
|
||||
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
|
||||
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
||||
const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com";
|
||||
const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/mcp/";
|
||||
|
||||
// Legacy vs new MCP gateway
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CodexAppsMcpGateway {
|
||||
LegacyMCPGateway,
|
||||
MCPGateway,
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
|
||||
match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) {
|
||||
@@ -65,7 +74,15 @@ fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option<HashMap<Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_url(base_url: &str) -> String {
|
||||
fn selected_config_codex_apps_mcp_gateway(config: &Config) -> CodexAppsMcpGateway {
|
||||
if config.features.enabled(Feature::AppsMcpGateway) {
|
||||
CodexAppsMcpGateway::MCPGateway
|
||||
} else {
|
||||
CodexAppsMcpGateway::LegacyMCPGateway
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_codex_apps_base_url(base_url: &str) -> String {
|
||||
let mut base_url = base_url.trim_end_matches('/').to_string();
|
||||
if (base_url.starts_with("https://chatgpt.com")
|
||||
|| base_url.starts_with("https://chat.openai.com"))
|
||||
@@ -73,6 +90,15 @@ fn codex_apps_mcp_url(base_url: &str) -> String {
|
||||
{
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
base_url
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_url_for_gateway(base_url: &str, gateway: CodexAppsMcpGateway) -> String {
|
||||
if gateway == CodexAppsMcpGateway::MCPGateway {
|
||||
return format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}");
|
||||
}
|
||||
|
||||
let base_url = normalize_codex_apps_base_url(base_url);
|
||||
if base_url.contains("/backend-api") {
|
||||
format!("{base_url}/wham/apps")
|
||||
} else if base_url.contains("/api/codex") {
|
||||
@@ -82,6 +108,13 @@ fn codex_apps_mcp_url(base_url: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn codex_apps_mcp_url(config: &Config) -> String {
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
&config.chatgpt_base_url,
|
||||
selected_config_codex_apps_mcp_gateway(config),
|
||||
)
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> McpServerConfig {
|
||||
let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var();
|
||||
let http_headers = if bearer_token_env_var.is_some() {
|
||||
@@ -89,7 +122,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
|
||||
} else {
|
||||
codex_apps_mcp_http_headers(auth)
|
||||
};
|
||||
let url = codex_apps_mcp_url(&config.chatgpt_base_url);
|
||||
let url = codex_apps_mcp_url(config);
|
||||
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
@@ -385,4 +418,128 @@ mod tests {
|
||||
|
||||
assert_eq!(group_tools_by_server(&tools), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() {
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
"https://chatgpt.com/backend-api",
|
||||
CodexAppsMcpGateway::LegacyMCPGateway
|
||||
),
|
||||
"https://chatgpt.com/backend-api/wham/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
"https://chat.openai.com",
|
||||
CodexAppsMcpGateway::LegacyMCPGateway
|
||||
),
|
||||
"https://chat.openai.com/backend-api/wham/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
"http://localhost:8080/api/codex",
|
||||
CodexAppsMcpGateway::LegacyMCPGateway
|
||||
),
|
||||
"http://localhost:8080/api/codex/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
"http://localhost:8080",
|
||||
CodexAppsMcpGateway::LegacyMCPGateway
|
||||
),
|
||||
"http://localhost:8080/api/codex/apps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_for_gateway_uses_openai_connectors_gateway() {
|
||||
let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}");
|
||||
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
"https://chatgpt.com/backend-api",
|
||||
CodexAppsMcpGateway::MCPGateway
|
||||
),
|
||||
expected_url.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
"https://chat.openai.com",
|
||||
CodexAppsMcpGateway::MCPGateway
|
||||
),
|
||||
expected_url.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
"http://localhost:8080/api/codex",
|
||||
CodexAppsMcpGateway::MCPGateway
|
||||
),
|
||||
expected_url.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_gateway(
|
||||
"http://localhost:8080",
|
||||
CodexAppsMcpGateway::MCPGateway
|
||||
),
|
||||
expected_url.as_str()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_uses_default_gateway_when_feature_is_disabled() {
|
||||
let mut config = crate::config::test_config();
|
||||
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
||||
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url(&config),
|
||||
"https://chatgpt.com/backend-api/wham/apps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_uses_openai_connectors_gateway_when_feature_is_enabled() {
|
||||
let mut config = crate::config::test_config();
|
||||
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
||||
config.features.enable(Feature::AppsMcpGateway);
|
||||
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url(&config),
|
||||
format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_server_config_switches_gateway_with_flags() {
|
||||
let mut config = crate::config::test_config();
|
||||
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
||||
|
||||
let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config);
|
||||
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
|
||||
|
||||
config.features.enable(Feature::Apps);
|
||||
|
||||
servers = with_codex_apps_mcp(servers, true, None, &config);
|
||||
let server = servers
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.expect("codex apps should be present when apps is enabled");
|
||||
let url = match &server.transport {
|
||||
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
||||
_ => panic!("expected streamable http transport for codex apps"),
|
||||
};
|
||||
|
||||
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
|
||||
|
||||
config.features.enable(Feature::AppsMcpGateway);
|
||||
servers = with_codex_apps_mcp(servers, true, None, &config);
|
||||
let server = servers
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.expect("codex apps should remain present when apps stays enabled");
|
||||
let url = match &server.transport {
|
||||
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
||||
_ => panic!("expected streamable http transport for codex apps"),
|
||||
};
|
||||
|
||||
let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}");
|
||||
assert_eq!(url, &expected_url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,7 +1123,12 @@ fn read_cached_codex_apps_tools() -> Option<Vec<ToolInfo>> {
|
||||
return Some(cached.tools.clone());
|
||||
}
|
||||
|
||||
*cache_guard = None;
|
||||
if cache_guard
|
||||
.as_ref()
|
||||
.is_some_and(|cached| now >= cached.expires_at)
|
||||
{
|
||||
*cache_guard = None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1272,6 +1277,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_clean_codex_apps_tools_cache<T>(f: impl FnOnce() -> T) -> T {
|
||||
let previous_cache = {
|
||||
let mut cache_guard = CODEX_APPS_TOOLS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache_guard.take()
|
||||
};
|
||||
let result = f();
|
||||
let mut cache_guard = CODEX_APPS_TOOLS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache_guard = previous_cache;
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qualify_tools_short_non_duplicated_names() {
|
||||
let tools = vec![
|
||||
@@ -1424,6 +1444,47 @@ mod tests {
|
||||
assert_eq!(filtered[0].tool_name, "tool_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_tools_cache_is_overwritten_by_last_write() {
|
||||
with_clean_codex_apps_tools_cache(|| {
|
||||
let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")];
|
||||
let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")];
|
||||
|
||||
write_cached_codex_apps_tools(&tools_gateway_1);
|
||||
let cached_gateway_1 =
|
||||
read_cached_codex_apps_tools().expect("cache entry exists for first write");
|
||||
assert_eq!(cached_gateway_1[0].tool_name, "one");
|
||||
|
||||
write_cached_codex_apps_tools(&tools_gateway_2);
|
||||
let cached_gateway_2 =
|
||||
read_cached_codex_apps_tools().expect("cache entry exists for second write");
|
||||
assert_eq!(cached_gateway_2[0].tool_name, "two");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_tools_cache_is_cleared_when_expired() {
|
||||
with_clean_codex_apps_tools_cache(|| {
|
||||
let tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "stale_tool")];
|
||||
write_cached_codex_apps_tools(&tools);
|
||||
|
||||
{
|
||||
let mut cache_guard = CODEX_APPS_TOOLS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache_guard.as_mut().expect("cache exists").expires_at =
|
||||
Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
|
||||
assert!(read_cached_codex_apps_tools().is_none());
|
||||
|
||||
let cache_guard = CODEX_APPS_TOOLS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert!(cache_guard.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_prompts_for_github_pat() {
|
||||
let server_name = "github";
|
||||
|
||||
@@ -95,7 +95,8 @@ pub(in crate::memories) async fn run_global_memory_consolidation(
|
||||
let consolidation_config = {
|
||||
let mut consolidation_config = config.as_ref().clone();
|
||||
consolidation_config.cwd = root.clone();
|
||||
consolidation_config.approval_policy = Constrained::allow_only(AskForApproval::Never);
|
||||
consolidation_config.permissions.approval_policy =
|
||||
Constrained::allow_only(AskForApproval::Never);
|
||||
let mut writable_roots = Vec::new();
|
||||
match AbsolutePathBuf::from_absolute_path(consolidation_config.codex_home.clone()) {
|
||||
Ok(codex_home) => writable_roots.push(codex_home),
|
||||
@@ -112,6 +113,7 @@ pub(in crate::memories) async fn run_global_memory_consolidation(
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
if let Err(err) = consolidation_config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(consolidation_sandbox_policy)
|
||||
{
|
||||
|
||||
@@ -48,6 +48,12 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
||||
section.push_str("- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n");
|
||||
section.push_str("- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n");
|
||||
|
||||
if config.features.enabled(Feature::JsReplToolsOnly) {
|
||||
section.push_str("- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n");
|
||||
section
|
||||
.push_str("- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n");
|
||||
}
|
||||
|
||||
section.push_str("- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`.");
|
||||
|
||||
Some(section)
|
||||
@@ -413,6 +419,21 @@ mod tests {
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_tools_only_instructions_are_feature_gated() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features
|
||||
.enable(Feature::JsRepl)
|
||||
.enable(Feature::JsReplToolsOnly);
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.state`, `codex.tmpDir`, and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
/// When both system instructions *and* a project doc are present the two
|
||||
/// should be concatenated with the separator.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -14,6 +14,9 @@ use tokio::process::Child;
|
||||
use url::Url;
|
||||
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::seatbelt_permissions::MacOsPreferencesPermission;
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
use crate::seatbelt_permissions::build_seatbelt_extensions;
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::SpawnChildRequest;
|
||||
use crate::spawn::StdioPolicy;
|
||||
@@ -179,6 +182,24 @@ pub(crate) fn create_seatbelt_command_args(
|
||||
sandbox_policy_cwd: &Path,
|
||||
enforce_managed_network: bool,
|
||||
network: Option<&NetworkProxy>,
|
||||
) -> Vec<String> {
|
||||
create_seatbelt_command_args_with_extensions(
|
||||
command,
|
||||
sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
enforce_managed_network,
|
||||
network,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn create_seatbelt_command_args_with_extensions(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
enforce_managed_network: bool,
|
||||
network: Option<&NetworkProxy>,
|
||||
extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> Vec<String> {
|
||||
let (file_write_policy, file_write_dir_params) = {
|
||||
if sandbox_policy.has_full_disk_write_access() {
|
||||
@@ -275,15 +296,33 @@ pub(crate) fn create_seatbelt_command_args(
|
||||
|
||||
let proxy = proxy_policy_inputs(network);
|
||||
let network_policy = dynamic_network_policy(sandbox_policy, enforce_managed_network, &proxy);
|
||||
|
||||
let full_policy = format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
|
||||
let seatbelt_extensions = extensions.map_or_else(
|
||||
|| {
|
||||
// Backward-compatibility default when no extension profile is provided.
|
||||
build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadOnly,
|
||||
..Default::default()
|
||||
})
|
||||
},
|
||||
build_seatbelt_extensions,
|
||||
);
|
||||
|
||||
let full_policy = if seatbelt_extensions.policy.is_empty() {
|
||||
format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}\n{}",
|
||||
seatbelt_extensions.policy
|
||||
)
|
||||
};
|
||||
|
||||
let dir_params = [
|
||||
file_read_dir_params,
|
||||
file_write_dir_params,
|
||||
macos_dir_params(),
|
||||
seatbelt_extensions.dir_params,
|
||||
]
|
||||
.concat();
|
||||
|
||||
@@ -328,10 +367,14 @@ mod tests {
|
||||
use super::MACOS_SEATBELT_BASE_POLICY;
|
||||
use super::ProxyPolicyInputs;
|
||||
use super::create_seatbelt_command_args;
|
||||
use super::create_seatbelt_command_args_with_extensions;
|
||||
use super::dynamic_network_policy;
|
||||
use super::macos_dir_params;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use crate::seatbelt_permissions::MacOsAutomationPermission;
|
||||
use crate::seatbelt_permissions::MacOsPreferencesPermission;
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -395,6 +438,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seatbelt_args_include_macos_permission_extensions() {
|
||||
let cwd = std::env::temp_dir();
|
||||
let args = create_seatbelt_command_args_with_extensions(
|
||||
vec!["echo".to_string(), "ok".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
cwd.as_path(),
|
||||
false,
|
||||
None,
|
||||
Some(&MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}),
|
||||
);
|
||||
let policy = &args[1];
|
||||
|
||||
assert!(policy.contains("(allow user-preference-write)"));
|
||||
assert!(policy.contains("(appleevent-destination \"com.apple.Notes\")"));
|
||||
assert!(policy.contains("com.apple.axserver"));
|
||||
assert!(policy.contains("com.apple.CalendarAgent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() {
|
||||
let cwd = std::env::temp_dir();
|
||||
let args = create_seatbelt_command_args(
|
||||
vec!["echo".to_string(), "ok".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
cwd.as_path(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let policy = &args[1];
|
||||
assert!(policy.contains("(allow user-preference-read)"));
|
||||
assert!(!policy.contains("(allow user-preference-write)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seatbelt_args_omit_macos_extensions_when_profile_is_empty() {
|
||||
let cwd = std::env::temp_dir();
|
||||
let args = create_seatbelt_command_args_with_extensions(
|
||||
vec!["echo".to_string(), "ok".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
cwd.as_path(),
|
||||
false,
|
||||
None,
|
||||
Some(&MacOsSeatbeltProfileExtensions::default()),
|
||||
);
|
||||
let policy = &args[1];
|
||||
assert!(!policy.contains("appleevent-send"));
|
||||
assert!(!policy.contains("com.apple.axserver"));
|
||||
assert!(!policy.contains("com.apple.CalendarAgent"));
|
||||
assert!(!policy.contains("user-preference-read"));
|
||||
assert!(!policy.contains("user-preference-write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() {
|
||||
let policy = dynamic_network_policy(
|
||||
@@ -564,6 +667,14 @@ mod tests {
|
||||
(allow file-write*
|
||||
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
|
||||
)
|
||||
|
||||
; macOS permission profile extensions
|
||||
(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs."))
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.cfprefsd.daemon")
|
||||
(global-name "com.apple.cfprefsd.agent")
|
||||
(local-name "com.apple.cfprefsd.agent"))
|
||||
(allow user-preference-read)
|
||||
"#,
|
||||
);
|
||||
|
||||
@@ -851,6 +962,14 @@ mod tests {
|
||||
(allow file-write*
|
||||
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
|
||||
)
|
||||
|
||||
; macOS permission profile extensions
|
||||
(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs."))
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.cfprefsd.daemon")
|
||||
(global-name "com.apple.cfprefsd.agent")
|
||||
(local-name "com.apple.cfprefsd.agent"))
|
||||
(allow user-preference-read)
|
||||
"#,
|
||||
);
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
(allow process-fork)
|
||||
(allow signal (target same-sandbox))
|
||||
|
||||
; Allow cf prefs to work.
|
||||
(allow user-preference-read)
|
||||
|
||||
; process-info
|
||||
(allow process-info* (target same-sandbox))
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum MacOsPreferencesPermission {
|
||||
#[default]
|
||||
None,
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum MacOsAutomationPermission {
|
||||
#[default]
|
||||
None,
|
||||
All,
|
||||
BundleIds(Vec<String>),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct MacOsSeatbeltProfileExtensions {
|
||||
pub macos_preferences: MacOsPreferencesPermission,
|
||||
pub macos_automation: MacOsAutomationPermission,
|
||||
pub macos_accessibility: bool,
|
||||
pub macos_calendar: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(crate) struct SeatbeltExtensionPolicy {
|
||||
pub(crate) policy: String,
|
||||
pub(crate) dir_params: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
impl MacOsSeatbeltProfileExtensions {
|
||||
pub fn normalized(&self) -> Self {
|
||||
let mut normalized = self.clone();
|
||||
if let MacOsAutomationPermission::BundleIds(bundle_ids) = &self.macos_automation {
|
||||
let bundle_ids = normalize_bundle_ids(bundle_ids);
|
||||
normalized.macos_automation = if bundle_ids.is_empty() {
|
||||
MacOsAutomationPermission::None
|
||||
} else {
|
||||
MacOsAutomationPermission::BundleIds(bundle_ids)
|
||||
};
|
||||
}
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_seatbelt_extensions(
|
||||
extensions: &MacOsSeatbeltProfileExtensions,
|
||||
) -> SeatbeltExtensionPolicy {
|
||||
let extensions = extensions.normalized();
|
||||
let mut clauses = Vec::new();
|
||||
|
||||
match extensions.macos_preferences {
|
||||
MacOsPreferencesPermission::None => {}
|
||||
MacOsPreferencesPermission::ReadOnly => {
|
||||
clauses.push(
|
||||
"(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))"
|
||||
.to_string(),
|
||||
);
|
||||
clauses.push(
|
||||
"(allow mach-lookup\n (global-name \"com.apple.cfprefsd.daemon\")\n (global-name \"com.apple.cfprefsd.agent\")\n (local-name \"com.apple.cfprefsd.agent\"))"
|
||||
.to_string(),
|
||||
);
|
||||
clauses.push("(allow user-preference-read)".to_string());
|
||||
}
|
||||
MacOsPreferencesPermission::ReadWrite => {
|
||||
clauses.push(
|
||||
"(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))"
|
||||
.to_string(),
|
||||
);
|
||||
clauses.push(
|
||||
"(allow mach-lookup\n (global-name \"com.apple.cfprefsd.daemon\")\n (global-name \"com.apple.cfprefsd.agent\")\n (local-name \"com.apple.cfprefsd.agent\"))"
|
||||
.to_string(),
|
||||
);
|
||||
clauses.push("(allow user-preference-read)".to_string());
|
||||
clauses.push("(allow user-preference-write)".to_string());
|
||||
clauses.push(
|
||||
"(allow ipc-posix-shm-write-data (ipc-posix-name-prefix \"apple.cfprefs.\"))"
|
||||
.to_string(),
|
||||
);
|
||||
clauses.push(
|
||||
"(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match extensions.macos_automation {
|
||||
MacOsAutomationPermission::None => {}
|
||||
MacOsAutomationPermission::All => {
|
||||
clauses.push(
|
||||
"(allow mach-lookup\n (global-name \"com.apple.coreservices.launchservicesd\")\n (global-name \"com.apple.coreservices.appleevents\"))"
|
||||
.to_string(),
|
||||
);
|
||||
clauses.push("(allow appleevent-send)".to_string());
|
||||
}
|
||||
MacOsAutomationPermission::BundleIds(bundle_ids) => {
|
||||
if !bundle_ids.is_empty() {
|
||||
clauses.push(
|
||||
"(allow mach-lookup (global-name \"com.apple.coreservices.appleevents\"))"
|
||||
.to_string(),
|
||||
);
|
||||
let destinations = bundle_ids
|
||||
.iter()
|
||||
.map(|bundle_id| format!(" (appleevent-destination \"{bundle_id}\")"))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
clauses.push(format!("(allow appleevent-send\n{destinations}\n)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if extensions.macos_accessibility {
|
||||
clauses.push("(allow mach-lookup (local-name \"com.apple.axserver\"))".to_string());
|
||||
}
|
||||
|
||||
if extensions.macos_calendar {
|
||||
clauses.push("(allow mach-lookup (global-name \"com.apple.CalendarAgent\"))".to_string());
|
||||
}
|
||||
|
||||
if clauses.is_empty() {
|
||||
SeatbeltExtensionPolicy::default()
|
||||
} else {
|
||||
SeatbeltExtensionPolicy {
|
||||
policy: format!(
|
||||
"; macOS permission profile extensions\n{}\n",
|
||||
clauses.join("\n")
|
||||
),
|
||||
dir_params: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_bundle_ids(bundle_ids: &[String]) -> Vec<String> {
|
||||
let mut unique = BTreeSet::new();
|
||||
for bundle_id in bundle_ids {
|
||||
let candidate = bundle_id.trim();
|
||||
if is_valid_bundle_id(candidate) {
|
||||
unique.insert(candidate.to_string());
|
||||
}
|
||||
}
|
||||
unique.into_iter().collect()
|
||||
}
|
||||
|
||||
fn is_valid_bundle_id(bundle_id: &str) -> bool {
|
||||
if bundle_id.len() < 3 || !bundle_id.contains('.') {
|
||||
return false;
|
||||
}
|
||||
bundle_id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MacOsAutomationPermission;
|
||||
use super::MacOsPreferencesPermission;
|
||||
use super::MacOsSeatbeltProfileExtensions;
|
||||
use super::build_seatbelt_extensions;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn preferences_read_only_emits_read_clauses_only() {
|
||||
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadOnly,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(policy.policy.contains("(allow user-preference-read)"));
|
||||
assert!(!policy.policy.contains("(allow user-preference-write)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_read_write_emits_write_clauses() {
|
||||
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(policy.policy.contains("(allow user-preference-read)"));
|
||||
assert!(policy.policy.contains("(allow user-preference-write)"));
|
||||
assert!(policy.policy.contains(
|
||||
"(allow ipc-posix-shm-write-create (ipc-posix-name-prefix \"apple.cfprefs.\"))"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_all_emits_unscoped_appleevents() {
|
||||
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
|
||||
macos_automation: MacOsAutomationPermission::All,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(policy.policy.contains("(allow appleevent-send)"));
|
||||
assert!(
|
||||
policy
|
||||
.policy
|
||||
.contains("com.apple.coreservices.launchservicesd")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_bundle_ids_are_normalized_and_scoped() {
|
||||
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
|
||||
macos_automation: MacOsAutomationPermission::BundleIds(vec![
|
||||
" com.apple.Notes ".to_string(),
|
||||
"com.apple.Calendar".to_string(),
|
||||
"bad bundle".to_string(),
|
||||
"com.apple.Notes".to_string(),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(
|
||||
policy
|
||||
.policy
|
||||
.contains("(appleevent-destination \"com.apple.Calendar\")")
|
||||
);
|
||||
assert!(
|
||||
policy
|
||||
.policy
|
||||
.contains("(appleevent-destination \"com.apple.Notes\")")
|
||||
);
|
||||
assert!(!policy.policy.contains("bad bundle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessibility_and_calendar_emit_mach_lookups() {
|
||||
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions {
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(policy.policy.contains("com.apple.axserver"));
|
||||
assert!(policy.policy.contains("com.apple.CalendarAgent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_extensions_emit_empty_policy() {
|
||||
let policy = build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default());
|
||||
assert_eq!(policy.policy, "");
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ async fn start_review_conversation(
|
||||
|
||||
// Set explicit review rubric for the sub-agent
|
||||
sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string());
|
||||
sub_agent_config.approval_policy = Constrained::allow_only(AskForApproval::Never);
|
||||
sub_agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
|
||||
|
||||
let model = config
|
||||
.review_model
|
||||
|
||||
@@ -818,11 +818,12 @@ fn build_agent_shared_config(
|
||||
config.model_reasoning_summary = turn.reasoning_summary;
|
||||
config.developer_instructions = turn.developer_instructions.clone();
|
||||
config.compact_prompt = turn.compact_prompt.clone();
|
||||
config.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
config.permissions.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
|
||||
config.cwd = turn.cwd.clone();
|
||||
config.approval_policy = Constrained::allow_only(AskForApproval::Never);
|
||||
config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
|
||||
config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(turn.sandbox_policy.clone())
|
||||
.map_err(|err| {
|
||||
@@ -1717,8 +1718,8 @@ mod tests {
|
||||
turn.cwd = temp_dir.path().to_path_buf();
|
||||
turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo"));
|
||||
turn.sandbox_policy = pick_allowed_sandbox_policy(
|
||||
&turn.config.sandbox_policy,
|
||||
turn.config.sandbox_policy.get().clone(),
|
||||
&turn.config.permissions.sandbox_policy,
|
||||
turn.config.permissions.sandbox_policy.get().clone(),
|
||||
);
|
||||
|
||||
let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config");
|
||||
@@ -1730,14 +1731,16 @@ mod tests {
|
||||
expected.model_reasoning_summary = turn.reasoning_summary;
|
||||
expected.developer_instructions = turn.developer_instructions.clone();
|
||||
expected.compact_prompt = turn.compact_prompt.clone();
|
||||
expected.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
|
||||
expected.cwd = turn.cwd.clone();
|
||||
expected
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::Never)
|
||||
.expect("approval policy set");
|
||||
expected
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(turn.sandbox_policy)
|
||||
.expect("sandbox policy set");
|
||||
@@ -1777,14 +1780,16 @@ mod tests {
|
||||
expected.model_reasoning_summary = turn.reasoning_summary;
|
||||
expected.developer_instructions = turn.developer_instructions.clone();
|
||||
expected.compact_prompt = turn.compact_prompt.clone();
|
||||
expected.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
|
||||
expected.cwd = turn.cwd.clone();
|
||||
expected
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::Never)
|
||||
.expect("approval policy set");
|
||||
expected
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(turn.sandbox_policy)
|
||||
.expect("sandbox policy set");
|
||||
|
||||
@@ -2,11 +2,17 @@ use async_trait::async_trait;
|
||||
use bm25::Document;
|
||||
use bm25::Language;
|
||||
use bm25::SearchEngineBuilder;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
@@ -76,7 +82,10 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
payload, session, ..
|
||||
payload,
|
||||
session,
|
||||
turn,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
@@ -111,6 +120,15 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let mcp_tools = if turn.config.features.enabled(Feature::Apps) {
|
||||
let connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
filter_codex_apps_mcp_tools(mcp_tools, &connectors)
|
||||
} else {
|
||||
mcp_tools
|
||||
};
|
||||
|
||||
let mut entries: Vec<ToolEntry> = mcp_tools
|
||||
.into_iter()
|
||||
@@ -178,6 +196,28 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_codex_apps_mcp_tools(
|
||||
mut mcp_tools: HashMap<String, ToolInfo>,
|
||||
connectors: &[AppInfo],
|
||||
) -> HashMap<String, ToolInfo> {
|
||||
let enabled_connectors: HashSet<&str> = connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_enabled)
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect();
|
||||
|
||||
mcp_tools.retain(|_, tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return true;
|
||||
}
|
||||
|
||||
tool.connector_id
|
||||
.as_deref()
|
||||
.is_some_and(|connector_id| enabled_connectors.contains(connector_id))
|
||||
});
|
||||
mcp_tools
|
||||
}
|
||||
|
||||
fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String {
|
||||
let mut parts = vec![
|
||||
name.to_string(),
|
||||
@@ -215,3 +255,112 @@ fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> Stri
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_connector(id: &str, enabled: bool) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tool(
|
||||
qualified_name: &str,
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
) -> (String, ToolInfo) {
|
||||
(
|
||||
qualified_name.to_string(),
|
||||
ToolInfo {
|
||||
server_name: server_name.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool: Tool {
|
||||
name: tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: Some(format!("Test tool: {tool_name}").into()),
|
||||
input_schema: Arc::new(JsonObject::default()),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_id.map(str::to_string),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_codex_apps_mcp_tools_keeps_non_apps_and_enabled_apps() {
|
||||
let mcp_tools = HashMap::from([
|
||||
make_tool(
|
||||
"mcp__codex_apps__calendar_create_event",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
Some("calendar"),
|
||||
),
|
||||
make_tool(
|
||||
"mcp__codex_apps__drive_search",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"drive_search",
|
||||
Some("drive"),
|
||||
),
|
||||
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
|
||||
]);
|
||||
let connectors = vec![
|
||||
make_connector("calendar", false),
|
||||
make_connector("drive", true),
|
||||
];
|
||||
|
||||
let mut filtered: Vec<String> = filter_codex_apps_mcp_tools(mcp_tools, &connectors)
|
||||
.into_keys()
|
||||
.collect();
|
||||
filtered.sort();
|
||||
|
||||
assert_eq!(
|
||||
filtered,
|
||||
vec![
|
||||
"mcp__codex_apps__drive_search".to_string(),
|
||||
"mcp__rmcp__echo".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() {
|
||||
let mcp_tools = HashMap::from([
|
||||
make_tool(
|
||||
"mcp__codex_apps__unknown",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"unknown",
|
||||
None,
|
||||
),
|
||||
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
|
||||
]);
|
||||
|
||||
let mut filtered: Vec<String> =
|
||||
filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)])
|
||||
.into_keys()
|
||||
.collect();
|
||||
filtered.sort();
|
||||
|
||||
assert_eq!(filtered, vec!["mcp__rmcp__echo".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,7 +680,13 @@ impl JsReplManager {
|
||||
};
|
||||
|
||||
match router
|
||||
.dispatch_tool_call(exec.session, exec.turn, exec.tracker, call)
|
||||
.dispatch_tool_call(
|
||||
exec.session,
|
||||
exec.turn,
|
||||
exec.tracker,
|
||||
call,
|
||||
crate::tools::router::ToolCallSource::JsRepl,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => match serde_json::to_value(response) {
|
||||
|
||||
@@ -85,7 +85,13 @@ impl ToolCallRuntime {
|
||||
};
|
||||
|
||||
router
|
||||
.dispatch_tool_call(session, turn, tracker, call.clone())
|
||||
.dispatch_tool_call(
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
call.clone(),
|
||||
crate::tools::router::ToolCallSource::Direct,
|
||||
)
|
||||
.instrument(dispatch_span.clone())
|
||||
.await
|
||||
} => res,
|
||||
|
||||
@@ -28,6 +28,12 @@ pub struct ToolCall {
|
||||
pub payload: ToolPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ToolCallSource {
|
||||
Direct,
|
||||
JsRepl,
|
||||
}
|
||||
|
||||
pub struct ToolRouter {
|
||||
registry: ToolRegistry,
|
||||
specs: Vec<ConfiguredToolSpec>,
|
||||
@@ -138,6 +144,7 @@ impl ToolRouter {
|
||||
turn: Arc<TurnContext>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
) -> Result<ResponseInputItem, FunctionCallError> {
|
||||
let ToolCall {
|
||||
tool_name,
|
||||
@@ -147,6 +154,21 @@ impl ToolRouter {
|
||||
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
|
||||
let failure_call_id = call_id.clone();
|
||||
|
||||
if source == ToolCallSource::Direct
|
||||
&& turn.tools_config.js_repl_tools_only
|
||||
&& !matches!(tool_name.as_str(), "js_repl" | "js_repl_reset")
|
||||
{
|
||||
let err = FunctionCallError::RespondToModel(
|
||||
"direct tool calls are disabled; use js_repl and codex.tool(...) instead"
|
||||
.to_string(),
|
||||
);
|
||||
return Ok(Self::failure_response(
|
||||
failure_call_id,
|
||||
payload_outputs_custom,
|
||||
err,
|
||||
));
|
||||
}
|
||||
|
||||
let invocation = ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
@@ -189,3 +211,118 @@ impl ToolRouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
use super::ToolCall;
|
||||
use super::ToolCallSource;
|
||||
use super::ToolRouter;
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
turn.tools_config.js_repl_tools_only = true;
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let mcp_tools = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
turn.dynamic_tools.as_slice(),
|
||||
);
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: "shell".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let response = router
|
||||
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::Direct)
|
||||
.await?;
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
let content = output.text_content().unwrap_or_default();
|
||||
assert!(
|
||||
content.contains("direct tool calls are disabled"),
|
||||
"unexpected tool call message: {content}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected function call output, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()> {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
turn.tools_config.js_repl_tools_only = true;
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let mcp_tools = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
turn.dynamic_tools.as_slice(),
|
||||
);
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: "shell".to_string(),
|
||||
call_id: "call-2".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let response = router
|
||||
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::JsRepl)
|
||||
.await?;
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
let content = output.text_content().unwrap_or_default();
|
||||
assert!(
|
||||
!content.contains("direct tool calls are disabled"),
|
||||
"js_repl source should bypass direct-call policy gate"
|
||||
);
|
||||
}
|
||||
other => panic!("expected function call output, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub web_search_mode: Option<WebSearchMode>,
|
||||
pub search_tool: bool,
|
||||
pub js_repl_enabled: bool,
|
||||
pub js_repl_tools_only: bool,
|
||||
pub collab_tools: bool,
|
||||
pub collaboration_modes_tools: bool,
|
||||
pub request_rule_enabled: bool,
|
||||
@@ -55,6 +56,8 @@ impl ToolsConfig {
|
||||
} = params;
|
||||
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
|
||||
let include_js_repl = features.enabled(Feature::JsRepl);
|
||||
let include_js_repl_tools_only =
|
||||
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
|
||||
let include_collab_tools = features.enabled(Feature::Collab);
|
||||
let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes);
|
||||
let request_rule_enabled = features.enabled(Feature::RequestRule);
|
||||
@@ -91,6 +94,7 @@ impl ToolsConfig {
|
||||
web_search_mode: *web_search_mode,
|
||||
search_tool: include_search_tool,
|
||||
js_repl_enabled: include_js_repl,
|
||||
js_repl_tools_only: include_js_repl_tools_only,
|
||||
collab_tools: include_collab_tools,
|
||||
collaboration_modes_tools: include_collaboration_modes_tools,
|
||||
request_rule_enabled,
|
||||
@@ -99,8 +103,15 @@ impl ToolsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn filter_tools_for_model(tools: Vec<ToolSpec>, _config: &ToolsConfig) -> Vec<ToolSpec> {
|
||||
pub(crate) fn filter_tools_for_model(tools: Vec<ToolSpec>, config: &ToolsConfig) -> Vec<ToolSpec> {
|
||||
if !config.js_repl_tools_only {
|
||||
return tools;
|
||||
}
|
||||
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|spec| matches!(spec.name(), "js_repl" | "js_repl_reset"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generic JSON‑Schema subset needed for our tool definitions
|
||||
@@ -1685,6 +1696,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contains_tool_specs(tools: &[ToolSpec], expected_subset: &[&str]) {
|
||||
use std::collections::HashSet;
|
||||
let mut names = HashSet::new();
|
||||
let mut duplicates = Vec::new();
|
||||
for name in tools.iter().map(tool_name) {
|
||||
if !names.insert(name) {
|
||||
duplicates.push(name);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
duplicates.is_empty(),
|
||||
"duplicate tool entries detected: {duplicates:?}"
|
||||
);
|
||||
for expected in expected_subset {
|
||||
assert!(
|
||||
names.contains(expected),
|
||||
"expected tool {expected} to be present; had: {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> {
|
||||
match config.shell_type {
|
||||
ConfigShellToolType::Default => Some("shell"),
|
||||
@@ -1910,6 +1942,77 @@ mod tests {
|
||||
assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_tools_only_filters_model_tools() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::JsRepl);
|
||||
features.enable(Feature::JsReplToolsOnly);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, &[]).build();
|
||||
let filtered = filter_tools_for_model(
|
||||
tools.iter().map(|tool| tool.spec.clone()).collect(),
|
||||
&tools_config,
|
||||
);
|
||||
assert_contains_tool_specs(&filtered, &["js_repl", "js_repl_reset"]);
|
||||
assert!(
|
||||
!filtered.iter().any(|tool| tool_name(tool) == "shell"),
|
||||
"expected non-js_repl tools to be hidden when js_repl_tools_only is enabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_tools_only_hides_dynamic_tools_from_model_tools() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::JsRepl);
|
||||
features.enable(Feature::JsReplToolsOnly);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let dynamic_tools = vec![DynamicToolSpec {
|
||||
name: "dynamic_echo".to_string(),
|
||||
description: "echo dynamic payload".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"}
|
||||
},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
}];
|
||||
let (tools, _) = build_specs(&tools_config, None, &dynamic_tools).build();
|
||||
assert!(
|
||||
tools.iter().any(|tool| tool.spec.name() == "dynamic_echo"),
|
||||
"expected dynamic tool in full router specs"
|
||||
);
|
||||
|
||||
let filtered = filter_tools_for_model(
|
||||
tools.iter().map(|tool| tool.spec.clone()).collect(),
|
||||
&tools_config,
|
||||
);
|
||||
assert!(
|
||||
!filtered
|
||||
.iter()
|
||||
.any(|tool| tool_name(tool) == "dynamic_echo"),
|
||||
"expected dynamic tools to be hidden from direct model tools in js_repl_tools_only mode"
|
||||
);
|
||||
assert_contains_tool_specs(&filtered, &["js_repl", "js_repl_reset"]);
|
||||
}
|
||||
|
||||
fn assert_model_tools(
|
||||
model_slug: &str,
|
||||
features: &Features,
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Kill switch for the elevated sandbox NUX on Windows.
|
||||
///
|
||||
@@ -24,7 +25,7 @@ pub trait WindowsSandboxLevelExt {
|
||||
|
||||
impl WindowsSandboxLevelExt for WindowsSandboxLevel {
|
||||
fn from_config(config: &Config) -> WindowsSandboxLevel {
|
||||
match config.windows_sandbox_mode {
|
||||
match config.permissions.windows_sandbox_mode {
|
||||
Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated,
|
||||
Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken,
|
||||
None => Self::from_features(&config.features),
|
||||
@@ -200,6 +201,25 @@ pub fn run_legacy_setup_preflight(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn run_setup_refresh_with_extra_read_roots(
|
||||
policy: &SandboxPolicy,
|
||||
policy_cwd: &Path,
|
||||
command_cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
codex_home: &Path,
|
||||
extra_read_roots: Vec<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
codex_windows_sandbox::run_setup_refresh_with_extra_read_roots(
|
||||
policy,
|
||||
policy_cwd,
|
||||
command_cwd,
|
||||
env_map,
|
||||
codex_home,
|
||||
extra_read_roots,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn run_legacy_setup_preflight(
|
||||
_policy: &SandboxPolicy,
|
||||
@@ -211,6 +231,18 @@ pub fn run_legacy_setup_preflight(
|
||||
anyhow::bail!("legacy Windows sandbox setup is only supported on Windows")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn run_setup_refresh_with_extra_read_roots(
|
||||
_policy: &SandboxPolicy,
|
||||
_policy_cwd: &Path,
|
||||
_command_cwd: &Path,
|
||||
_env_map: &HashMap<String, String>,
|
||||
_codex_home: &Path,
|
||||
_extra_read_roots: Vec<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
anyhow::bail!("Windows sandbox read-root refresh is only supported on Windows")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::windows_sandbox::run_setup_refresh_with_extra_read_roots;
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn grant_read_root_non_elevated(
|
||||
policy: &SandboxPolicy,
|
||||
policy_cwd: &Path,
|
||||
command_cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
codex_home: &Path,
|
||||
read_root: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
if !read_root.is_absolute() {
|
||||
anyhow::bail!("path must be absolute: {}", read_root.display());
|
||||
}
|
||||
if !read_root.exists() {
|
||||
anyhow::bail!("path does not exist: {}", read_root.display());
|
||||
}
|
||||
if !read_root.is_dir() {
|
||||
anyhow::bail!("path must be a directory: {}", read_root.display());
|
||||
}
|
||||
|
||||
let canonical_root = dunce::canonicalize(read_root)?;
|
||||
run_setup_refresh_with_extra_read_roots(
|
||||
policy,
|
||||
policy_cwd,
|
||||
command_cwd,
|
||||
env_map,
|
||||
codex_home,
|
||||
vec![canonical_root.clone()],
|
||||
)?;
|
||||
Ok(canonical_root)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::grant_read_root_non_elevated;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn policy() -> SandboxPolicy {
|
||||
SandboxPolicy::new_workspace_write_policy()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_relative_path() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let err = grant_read_root_non_elevated(
|
||||
&policy(),
|
||||
tmp.path(),
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
tmp.path(),
|
||||
Path::new("relative"),
|
||||
)
|
||||
.expect_err("relative path should fail");
|
||||
assert!(err.to_string().contains("path must be absolute"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_path() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let missing = tmp.path().join("does-not-exist");
|
||||
let err = grant_read_root_non_elevated(
|
||||
&policy(),
|
||||
tmp.path(),
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
tmp.path(),
|
||||
missing.as_path(),
|
||||
)
|
||||
.expect_err("missing path should fail");
|
||||
assert!(err.to_string().contains("path does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_file_path() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let file_path = tmp.path().join("file.txt");
|
||||
std::fs::write(&file_path, "hello").expect("write file");
|
||||
let err = grant_read_root_non_elevated(
|
||||
&policy(),
|
||||
tmp.path(),
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
tmp.path(),
|
||||
file_path.as_path(),
|
||||
)
|
||||
.expect_err("file path should fail");
|
||||
assert!(err.to_string().contains("path must be a directory"));
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,6 @@ distilled into MEMORY.md and memory_summary.md.
|
||||
There is no strict size limit, and you should feel free to list a lot of points here as
|
||||
long as they are helpful.
|
||||
Instructional notes in angle brackets are guidance only; do not include them verbatim in the rollout summary.
|
||||
Use absolute paths for any file paths and commands. You should refer to the cwd of the rollout.
|
||||
|
||||
Template (items are flexible; include only what is useful):
|
||||
|
||||
|
||||
@@ -1467,8 +1467,8 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
|
||||
let model = model_override.unwrap_or("gpt-5.1");
|
||||
|
||||
let mut builder = test_codex().with_model(model).with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.sandbox_policy = Constrained::allow_any(sandbox_policy.clone());
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy.clone());
|
||||
for feature in features {
|
||||
config.features.enable(feature);
|
||||
}
|
||||
@@ -1585,8 +1585,8 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file()
|
||||
let mut builder = test_codex()
|
||||
.with_model("gpt-5.1-codex")
|
||||
.with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -1692,8 +1692,8 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let sandbox_policy_for_config = sandbox_policy.clone();
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let allow_prefix_path = test.cwd.path().join("allow-prefix.txt");
|
||||
|
||||
@@ -973,8 +973,8 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.approval_policy.value(),
|
||||
sandbox_policy: config.sandbox_policy.get().clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: session_configured.model.clone(),
|
||||
effort: Some(ReasoningEffort::Low),
|
||||
summary: config.model_reasoning_summary,
|
||||
|
||||
@@ -63,8 +63,9 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() {
|
||||
// Build a conversation configured to require approvals so the delegate
|
||||
// routes ExecApprovalRequest via the parent.
|
||||
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.sandbox_policy =
|
||||
Constrained::allow_any(SandboxPolicy::new_read_only_policy());
|
||||
});
|
||||
let test = builder.build(&server).await.expect("build test codex");
|
||||
|
||||
@@ -144,9 +145,10 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() {
|
||||
mount_sse_sequence(&server, vec![sse1, sse2]).await;
|
||||
|
||||
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
// Use a restricted sandbox so patch approval is required
|
||||
config.sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
|
||||
config.permissions.sandbox_policy =
|
||||
Constrained::allow_any(SandboxPolicy::new_read_only_policy());
|
||||
config.include_apply_patch_tool = true;
|
||||
});
|
||||
let test = builder.build(&server).await.expect("build test codex");
|
||||
|
||||
@@ -165,8 +165,8 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> {
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
sandbox_policy: test.config.sandbox_policy.get().clone(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: test.config.permissions.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: test.config.model_reasoning_summary,
|
||||
@@ -271,8 +271,8 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: test.config.cwd.clone(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
sandbox_policy: test.config.sandbox_policy.get().clone(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: test.config.permissions.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: test.config.model_reasoning_summary,
|
||||
|
||||
@@ -964,8 +964,9 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.sandbox_policy = Constrained::allow_any(SandboxPolicy::DangerFullAccess);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.sandbox_policy =
|
||||
Constrained::allow_any(SandboxPolicy::DangerFullAccess);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1015,7 +1016,8 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
config.permissions.approval_policy =
|
||||
Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1080,7 +1082,8 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
config.permissions.approval_policy =
|
||||
Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1145,7 +1148,8 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
config.permissions.approval_policy =
|
||||
Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1210,7 +1214,8 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
|
||||
.await;
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
config.permissions.approval_policy =
|
||||
Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1275,7 +1280,8 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
config.permissions.approval_policy =
|
||||
Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1341,7 +1347,8 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
config.permissions.approval_policy =
|
||||
Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
|
||||
@@ -108,7 +108,7 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -96,7 +96,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -168,7 +168,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -230,7 +230,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial
|
||||
@@ -329,7 +329,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial
|
||||
@@ -384,7 +384,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
assert_eq!(permissions_base.len(), 2);
|
||||
|
||||
builder = builder.with_config(|config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
});
|
||||
let resumed = builder.resume(&server, home, rollout_path.clone()).await?;
|
||||
resumed
|
||||
@@ -410,7 +410,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
|
||||
assert!(!permissions_base.contains(permissions_resume.last().expect("new permissions")));
|
||||
|
||||
let mut fork_config = initial.config.clone();
|
||||
fork_config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
fork_config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
let forked = initial
|
||||
.thread_manager
|
||||
.fork_thread(usize::MAX, fork_config, rollout_path, false)
|
||||
@@ -465,8 +465,8 @@ async fn permissions_message_includes_writable_roots() -> Result<()> {
|
||||
let sandbox_policy_for_config = sandbox_policy.clone();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -141,7 +141,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result<
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -195,7 +195,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> {
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -255,7 +255,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -303,7 +303,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -337,7 +337,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -400,7 +400,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -434,7 +434,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -507,7 +507,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
@@ -541,7 +541,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: test.config.approval_policy.value(),
|
||||
approval_policy: test.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
|
||||
@@ -733,8 +733,8 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
.await?;
|
||||
|
||||
let default_cwd = config.cwd.clone();
|
||||
let default_approval_policy = config.approval_policy.value();
|
||||
let default_sandbox_policy = config.sandbox_policy.get();
|
||||
let default_approval_policy = config.permissions.approval_policy.value();
|
||||
let default_sandbox_policy = config.permissions.sandbox_policy.get();
|
||||
let default_model = session_configured.model;
|
||||
let default_effort = config.model_reasoning_effort;
|
||||
let default_summary = config.model_reasoning_summary;
|
||||
@@ -841,8 +841,8 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
.await?;
|
||||
|
||||
let default_cwd = config.cwd.clone();
|
||||
let default_approval_policy = config.approval_policy.value();
|
||||
let default_sandbox_policy = config.sandbox_policy.get();
|
||||
let default_approval_policy = config.permissions.approval_policy.value();
|
||||
let default_sandbox_policy = config.permissions.sandbox_policy.get();
|
||||
let default_model = session_configured.model;
|
||||
let default_effort = config.model_reasoning_effort;
|
||||
let default_summary = config.model_reasoning_summary;
|
||||
|
||||
@@ -177,8 +177,8 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<(
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: config.approval_policy.value(),
|
||||
sandbox_policy: config.sandbox_policy.get().clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: requested_model.to_string(),
|
||||
effort: None,
|
||||
summary: config.model_reasoning_summary,
|
||||
|
||||
@@ -22,8 +22,9 @@ fn resume_history(
|
||||
let turn_ctx = TurnContextItem {
|
||||
turn_id: None,
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.approval_policy.value(),
|
||||
sandbox_policy: config.sandbox_policy.get().clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
@@ -416,6 +416,7 @@ async fn shell_timeout_handles_background_grandchild_stdout() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
|
||||
config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::DangerFullAccess)
|
||||
.expect("set sandbox policy");
|
||||
@@ -511,7 +512,8 @@ async fn shell_spawn_failure_truncates_exec_error() -> Result<()> {
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|cfg| {
|
||||
cfg.sandbox_policy
|
||||
cfg.permissions
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::DangerFullAccess)
|
||||
.expect("set sandbox policy");
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ cargo run -p codex-debug-client -- --thread-id thr_123
|
||||
- `--codex-bin <path>`: path to the `codex` binary (default: `codex`).
|
||||
- `-c, --config key=value`: pass through `--config` overrides to `codex`.
|
||||
- `--thread-id <id>`: resume a thread instead of starting a new one.
|
||||
- `--approval-policy <policy>`: `untrusted`, `on-failure`, `on-request`, `never`.
|
||||
- `--approval-policy <policy>`: `untrusted`, `on-failure` (deprecated), `on-request`, `never`.
|
||||
- `--auto-approve`: auto-approve command/file-change approvals (default: decline).
|
||||
- `--final-only`: only show completed assistant messages and tool items.
|
||||
- `--model <name>`: optional model override for thread start/resume.
|
||||
|
||||
@@ -180,6 +180,7 @@ impl AppServerClient {
|
||||
model_providers: None,
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
},
|
||||
};
|
||||
self.send(&request)?;
|
||||
|
||||
@@ -59,7 +59,7 @@ Request `newConversation` params (subset):
|
||||
- `model`: string model id (e.g. "o3", "gpt-5.1", "gpt-5.1-codex")
|
||||
- `profile`: optional named profile
|
||||
- `cwd`: optional working directory
|
||||
- `approvalPolicy`: `untrusted` | `on-request` | `on-failure` | `never`
|
||||
- `approvalPolicy`: `untrusted` | `on-request` | `on-failure` (deprecated) | `never`
|
||||
- `sandbox`: `read-only` | `workspace-write` | `external-sandbox` (honors `networkAccess` restricted/enabled) | `danger-full-access`
|
||||
- `config`: map of additional config overrides
|
||||
- `baseInstructions`: optional instruction override
|
||||
@@ -81,9 +81,9 @@ Interrupt a running turn: `interruptConversation`.
|
||||
|
||||
List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`.
|
||||
|
||||
For v2 threads, use `thread/list` with `archived: true` to list archived rollouts and
|
||||
`thread/unarchive` to restore them to the active sessions directory (it returns the restored
|
||||
thread summary).
|
||||
For v2 threads, use `thread/list` with filters such as `archived: true` or `cwd: "/path"` to
|
||||
narrow results, and `thread/unarchive` to restore archived rollouts to the active sessions
|
||||
directory (it returns the restored thread summary).
|
||||
|
||||
## Models
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This crate contains the code for two executables:
|
||||
|
||||
- `codex-exec-mcp-server` is an MCP server that provides a tool named `shell` that runs a shell command inside a sandboxed instance of Bash. Every resulting `execve(2)` call made within Bash is intercepted and run via the executable defined by the `BASH_EXEC_WRAPPER` environment variable within the Bash process. In practice, `BASH_EXEC_WRAPPER` is set to `codex-execve-wrapper`.
|
||||
- `codex-exec-mcp-server` is an MCP server that provides a tool named `shell` that runs a shell command inside a sandboxed shell process. Every resulting `execve(2)` call made within that shell is intercepted and run via the executable defined by the `EXEC_WRAPPER` environment variable within the shell process. In practice, `EXEC_WRAPPER` is set to `codex-execve-wrapper`.
|
||||
- `codex-execve-wrapper` is the executable that takes the arguments to the `execve(2)` call and "escalates" it to the MCP server via a shared file descriptor (specified by the `CODEX_ESCALATE_SOCKET` environment variable) for consideration. Based on the [Codex `.rules`](https://developers.openai.com/codex/local-config#rules-preview), the MCP server replies with one of:
|
||||
- `Run`: `codex-execve-wrapper` should invoke `execve(2)` on itself to run the original command within Bash
|
||||
- `Escalate`: forward the file descriptors of the current process to the MCP server so the command can be run faithfully outside the sandbox. Because the MCP server will have the original FDs for `stdout` and `stderr`, it can write those directly. When the process completes, the MCP server forwards the exit code to `codex-execve-wrapper` so that it exits in a consistent manner.
|
||||
@@ -10,7 +10,7 @@ This crate contains the code for two executables:
|
||||
|
||||
## Patched Bash
|
||||
|
||||
We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `BASH_EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually:
|
||||
We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bminor/bash
|
||||
|
||||
@@ -5,11 +5,12 @@ use std::os::fd::OwnedFd;
|
||||
|
||||
use anyhow::Context as _;
|
||||
|
||||
use crate::posix::escalate_protocol::BASH_EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::EscalateAction;
|
||||
use crate::posix::escalate_protocol::EscalateRequest;
|
||||
use crate::posix::escalate_protocol::EscalateResponse;
|
||||
use crate::posix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::SuperExecMessage;
|
||||
use crate::posix::escalate_protocol::SuperExecResult;
|
||||
use crate::posix::socket::AsyncDatagramSocket;
|
||||
@@ -38,7 +39,7 @@ pub(crate) async fn run(file: String, argv: Vec<String>) -> anyhow::Result<i32>
|
||||
.filter(|(k, _)| {
|
||||
!matches!(
|
||||
k.as_str(),
|
||||
ESCALATE_SOCKET_ENV_VAR | BASH_EXEC_WRAPPER_ENV_VAR
|
||||
ESCALATE_SOCKET_ENV_VAR | EXEC_WRAPPER_ENV_VAR | LEGACY_BASH_EXEC_WRAPPER_ENV_VAR
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -8,8 +8,11 @@ use serde::Serialize;
|
||||
/// 'exec-server escalate' reads this to find the inherited FD for the escalate socket.
|
||||
pub(super) const ESCALATE_SOCKET_ENV_VAR: &str = "CODEX_ESCALATE_SOCKET";
|
||||
|
||||
/// The patched bash uses this to wrap exec() calls.
|
||||
pub(super) const BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER";
|
||||
/// Patched shells use this to wrap exec() calls.
|
||||
pub(super) const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER";
|
||||
|
||||
/// Compatibility alias for older patched bash builds.
|
||||
pub(super) const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER";
|
||||
|
||||
/// The client sends this to the server to request an exec() call.
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -15,11 +15,12 @@ use codex_core::sandboxing::SandboxPermissions;
|
||||
use tokio::process::Command;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::posix::escalate_protocol::BASH_EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::EscalateAction;
|
||||
use crate::posix::escalate_protocol::EscalateRequest;
|
||||
use crate::posix::escalate_protocol::EscalateResponse;
|
||||
use crate::posix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR;
|
||||
use crate::posix::escalate_protocol::SuperExecMessage;
|
||||
use crate::posix::escalate_protocol::SuperExecResult;
|
||||
use crate::posix::escalation_policy::EscalationPolicy;
|
||||
@@ -63,7 +64,11 @@ impl EscalateServer {
|
||||
client_socket.as_raw_fd().to_string(),
|
||||
);
|
||||
env.insert(
|
||||
BASH_EXEC_WRAPPER_ENV_VAR.to_string(),
|
||||
EXEC_WRAPPER_ENV_VAR.to_string(),
|
||||
self.execve_wrapper.to_string_lossy().to_string(),
|
||||
);
|
||||
env.insert(
|
||||
LEGACY_BASH_EXEC_WRAPPER_ENV_VAR.to_string(),
|
||||
self.execve_wrapper.to_string_lossy().to_string(),
|
||||
);
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ pub async fn create_transport<P>(
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let mcp_executable = codex_utils_cargo_bin::cargo_bin("codex-exec-mcp-server")?;
|
||||
let execve_wrapper = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")?;
|
||||
|
||||
// `bash` is a test resource rather than a binary target, so we must use
|
||||
// `find_resource!` to locate it instead of `cargo_bin()`.
|
||||
let bash = find_resource!("../suite/bash")?;
|
||||
@@ -51,8 +48,24 @@ where
|
||||
.await?;
|
||||
assert!(status.success(), "dotslash fetch failed: {status:?}");
|
||||
|
||||
create_transport_with_shell_path(codex_home, dotslash_cache, bash).await
|
||||
}
|
||||
|
||||
pub async fn create_transport_with_shell_path<P, Q, R>(
|
||||
codex_home: P,
|
||||
dotslash_cache: Q,
|
||||
shell_path: R,
|
||||
) -> anyhow::Result<TokioChildProcess>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
Q: AsRef<Path>,
|
||||
R: AsRef<Path>,
|
||||
{
|
||||
let mcp_executable = codex_utils_cargo_bin::cargo_bin("codex-exec-mcp-server")?;
|
||||
let execve_wrapper = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")?;
|
||||
|
||||
let transport = TokioChildProcess::new(Command::new(&mcp_executable).configure(|cmd| {
|
||||
cmd.arg("--bash").arg(bash);
|
||||
cmd.arg("--bash").arg(shell_path.as_ref());
|
||||
cmd.arg("--execve").arg(&execve_wrapper);
|
||||
cmd.env("CODEX_HOME", codex_home.as_ref());
|
||||
cmd.env("DOTSLASH_CACHE", dotslash_cache.as_ref());
|
||||
|
||||
@@ -10,6 +10,7 @@ use anyhow::ensure;
|
||||
use codex_exec_server::ExecResult;
|
||||
use exec_server_test_support::InteractiveClient;
|
||||
use exec_server_test_support::create_transport;
|
||||
use exec_server_test_support::create_transport_with_shell_path;
|
||||
use exec_server_test_support::notify_readable_sandbox;
|
||||
use exec_server_test_support::write_default_execpolicy;
|
||||
use maplit::hashset;
|
||||
@@ -54,7 +55,46 @@ prefix_rule(
|
||||
let dotslash_cache_temp_dir = TempDir::new()?;
|
||||
let dotslash_cache = dotslash_cache_temp_dir.path();
|
||||
let transport = create_transport(codex_home.as_ref(), dotslash_cache).await?;
|
||||
run_accept_elicitation_for_prompt_rule_with_transport(transport).await
|
||||
}
|
||||
|
||||
/// Verify the same prompt/escalation flow works when the server is launched
|
||||
/// with a patched zsh binary.
|
||||
///
|
||||
/// Set CODEX_TEST_ZSH_PATH to enable this test locally or in CI.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn accept_elicitation_for_prompt_rule_with_zsh() -> Result<()> {
|
||||
let Some(zsh_path) = std::env::var_os("CODEX_TEST_ZSH_PATH") else {
|
||||
eprintln!("skipping zsh test: CODEX_TEST_ZSH_PATH is not set");
|
||||
return Ok(());
|
||||
};
|
||||
let zsh_path = PathBuf::from(zsh_path);
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_default_execpolicy(
|
||||
r#"
|
||||
# Create a rule with `decision = "prompt"` to exercise the elicitation flow.
|
||||
prefix_rule(
|
||||
pattern = ["git", "init"],
|
||||
decision = "prompt",
|
||||
match = [
|
||||
"git init ."
|
||||
],
|
||||
)
|
||||
"#,
|
||||
codex_home.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let dotslash_cache_temp_dir = TempDir::new()?;
|
||||
let dotslash_cache = dotslash_cache_temp_dir.path();
|
||||
let transport =
|
||||
create_transport_with_shell_path(codex_home.as_ref(), dotslash_cache, &zsh_path).await?;
|
||||
run_accept_elicitation_for_prompt_rule_with_transport(transport).await
|
||||
}
|
||||
|
||||
async fn run_accept_elicitation_for_prompt_rule_with_transport(
|
||||
transport: rmcp::transport::TokioChildProcess,
|
||||
) -> Result<()> {
|
||||
// Create an MCP client that approves expected elicitation messages.
|
||||
let project_root = TempDir::new()?;
|
||||
let project_root_path = project_root.path().canonicalize().unwrap();
|
||||
|
||||
@@ -332,8 +332,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
}
|
||||
|
||||
let default_cwd = config.cwd.to_path_buf();
|
||||
let default_approval_policy = config.approval_policy.value();
|
||||
let default_sandbox_policy = config.sandbox_policy.get();
|
||||
let default_approval_policy = config.permissions.approval_policy.value();
|
||||
let default_sandbox_policy = config.permissions.sandbox_policy.get();
|
||||
let default_effort = config.model_reasoning_effort;
|
||||
let default_summary = config.model_reasoning_summary;
|
||||
|
||||
|
||||
@@ -260,6 +260,12 @@ pub enum Op {
|
||||
/// Request MCP servers to reinitialize and refresh cached tool lists.
|
||||
RefreshMcpServers { config: McpServerRefreshConfig },
|
||||
|
||||
/// Reload user config layer overrides for the active session.
|
||||
///
|
||||
/// This updates runtime config-derived behavior (for example app
|
||||
/// enable/disable state) without restarting the thread.
|
||||
ReloadUserConfig,
|
||||
|
||||
/// Request the list of available custom prompts.
|
||||
ListCustomPrompts,
|
||||
|
||||
@@ -356,10 +362,12 @@ pub enum AskForApproval {
|
||||
#[strum(serialize = "untrusted")]
|
||||
UnlessTrusted,
|
||||
|
||||
/// *All* commands are auto‑approved, but they are expected to run inside a
|
||||
/// sandbox where network access is disabled and writes are confined to a
|
||||
/// specific set of paths. If the command fails, it will be escalated to
|
||||
/// the user to approve execution without a sandbox.
|
||||
/// DEPRECATED: *All* commands are auto‑approved, but they are expected to
|
||||
/// run inside a sandbox where network access is disabled and writes are
|
||||
/// confined to a specific set of paths. If the command fails, it will be
|
||||
/// escalated to the user to approve execution without a sandbox.
|
||||
/// Prefer `OnRequest` for interactive runs or `Never` for non-interactive
|
||||
/// runs.
|
||||
OnFailure,
|
||||
|
||||
/// The model decides when to ask the user for approval.
|
||||
@@ -1930,6 +1938,12 @@ impl From<CompactedItem> for ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct TurnContextNetworkItem {
|
||||
pub allowed_domains: Vec<String>,
|
||||
pub denied_domains: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
|
||||
pub struct TurnContextItem {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -1937,6 +1951,8 @@ pub struct TurnContextItem {
|
||||
pub cwd: PathBuf,
|
||||
pub approval_policy: AskForApproval,
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub network: Option<TurnContextNetworkItem>,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub personality: Option<Personality>,
|
||||
@@ -2973,6 +2989,53 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_context_item_deserializes_without_network() -> Result<()> {
|
||||
let item: TurnContextItem = serde_json::from_value(json!({
|
||||
"cwd": "/tmp",
|
||||
"approval_policy": "never",
|
||||
"sandbox_policy": { "type": "danger-full-access" },
|
||||
"model": "gpt-5",
|
||||
"summary": "auto",
|
||||
}))?;
|
||||
|
||||
assert_eq!(item.network, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_context_item_serializes_network_when_present() -> Result<()> {
|
||||
let item = TurnContextItem {
|
||||
turn_id: None,
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
network: Some(TurnContextNetworkItem {
|
||||
allowed_domains: vec!["api.example.com".to_string()],
|
||||
denied_domains: vec!["blocked.example.com".to_string()],
|
||||
}),
|
||||
model: "gpt-5".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
effort: None,
|
||||
summary: ReasoningSummaryConfig::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(item)?;
|
||||
assert_eq!(
|
||||
value["network"],
|
||||
json!({
|
||||
"allowed_domains": ["api.example.com"],
|
||||
"denied_domains": ["blocked.example.com"],
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize Event to verify that its JSON representation has the expected
|
||||
/// amount of nesting.
|
||||
#[test]
|
||||
|
||||
@@ -104,25 +104,6 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
|
||||
let cmd0 = command.first().map(String::as_str);
|
||||
|
||||
match cmd0 {
|
||||
Some(cmd) if cmd.ends_with("git") => {
|
||||
let Some((subcommand_idx, subcommand)) =
|
||||
find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match subcommand {
|
||||
"reset" | "rm" => true,
|
||||
"branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
|
||||
"push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
|
||||
"clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
|
||||
other => {
|
||||
debug_assert!(false, "unexpected git subcommand from matcher: {other}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some("rm") => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf")),
|
||||
|
||||
// for sudo <cmd> simply do the check for <cmd>
|
||||
@@ -133,48 +114,6 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn git_branch_is_delete(branch_args: &[String]) -> bool {
|
||||
// Git allows stacking short flags (for example, `-dv` or `-vd`). Treat any
|
||||
// short-flag group containing `d`/`D` as a delete flag.
|
||||
branch_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(arg, "-d" | "-D" | "--delete")
|
||||
|| arg.starts_with("--delete=")
|
||||
|| short_flag_group_contains(arg, 'd')
|
||||
|| short_flag_group_contains(arg, 'D')
|
||||
})
|
||||
}
|
||||
|
||||
fn short_flag_group_contains(arg: &str, target: char) -> bool {
|
||||
arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
|
||||
}
|
||||
|
||||
fn git_push_is_dangerous(push_args: &[String]) -> bool {
|
||||
push_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(
|
||||
arg,
|
||||
"--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d"
|
||||
) || arg.starts_with("--force-with-lease=")
|
||||
|| arg.starts_with("--force-if-includes=")
|
||||
|| arg.starts_with("--delete=")
|
||||
|| short_flag_group_contains(arg, 'f')
|
||||
|| short_flag_group_contains(arg, 'd')
|
||||
|| git_push_refspec_is_dangerous(arg)
|
||||
})
|
||||
}
|
||||
|
||||
fn git_push_refspec_is_dangerous(arg: &str) -> bool {
|
||||
// `+<refspec>` forces updates and `:<dst>` deletes remote refs.
|
||||
(arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
|
||||
}
|
||||
|
||||
fn git_clean_is_force(clean_args: &[String]) -> bool {
|
||||
clean_args.iter().map(String::as_str).any(|arg| {
|
||||
matches!(arg, "--force" | "-f")
|
||||
|| arg.starts_with("--force=")
|
||||
|| short_flag_group_contains(arg, 'f')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -183,193 +122,6 @@ mod tests {
|
||||
items.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_reset_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&["git", "reset"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_git_reset_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git reset --hard",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zsh_git_reset_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"zsh",
|
||||
"-lc",
|
||||
"git reset --hard",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_status_is_not_dangerous() {
|
||||
assert!(!command_might_be_dangerous(&vec_str(&["git", "status"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_git_status_is_not_dangerous() {
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git status",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sudo_git_reset_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"sudo", "git", "reset", "--hard",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usr_bin_git_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"/usr/bin/git",
|
||||
"reset",
|
||||
"--hard",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-d", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-D", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git branch --delete feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-dv", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-vd", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-vD", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "branch", "-Dvv", "feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_branch_delete_with_global_options_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "-C", ".", "branch", "-d", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"-c",
|
||||
"color.ui=false",
|
||||
"branch",
|
||||
"-D",
|
||||
"feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git -C . branch -d feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_checkout_reset_is_not_dangerous() {
|
||||
// The first non-option token is "checkout", so later positional args
|
||||
// like branch names must not be treated as subcommands.
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"git", "checkout", "reset",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_force_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "--force", "origin", "main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "-f", "origin", "main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"-C",
|
||||
".",
|
||||
"push",
|
||||
"--force-with-lease",
|
||||
"origin",
|
||||
"main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_plus_refspec_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", "+main",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"+refs/heads/main:refs/heads/main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_delete_flag_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "--delete", "origin", "feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "-d", "origin", "feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_delete_refspec_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", ":feature",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
"git push origin :feature",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_push_without_force_is_not_dangerous() {
|
||||
assert!(!command_might_be_dangerous(&vec_str(&[
|
||||
"git", "push", "origin", "main",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "-fdx",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "-xdf",
|
||||
])));
|
||||
assert!(command_might_be_dangerous(&vec_str(&[
|
||||
"git", "clean", "--force",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rm_rf_is_dangerous() {
|
||||
assert!(command_might_be_dangerous(&vec_str(&["rm", "-rf", "/"])));
|
||||
|
||||
+194
-17
@@ -626,9 +626,16 @@ impl App {
|
||||
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
|
||||
}
|
||||
|
||||
async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> {
|
||||
let mut config = self.rebuild_config_for_cwd(self.config.cwd.clone()).await?;
|
||||
self.apply_runtime_policy_overrides(&mut config);
|
||||
self.config = config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
|
||||
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
|
||||
&& let Err(err) = config.approval_policy.set(*policy)
|
||||
&& let Err(err) = config.permissions.approval_policy.set(*policy)
|
||||
{
|
||||
tracing::warn!(%err, "failed to carry forward approval policy override");
|
||||
self.chat_widget.add_error_message(format!(
|
||||
@@ -636,7 +643,7 @@ impl App {
|
||||
));
|
||||
}
|
||||
if let Some(policy) = self.runtime_sandbox_policy_override.as_ref()
|
||||
&& let Err(err) = config.sandbox_policy.set(policy.clone())
|
||||
&& let Err(err) = config.permissions.sandbox_policy.set(policy.clone())
|
||||
{
|
||||
tracing::warn!(%err, "failed to carry forward sandbox policy override");
|
||||
self.chat_widget.add_error_message(format!(
|
||||
@@ -1156,7 +1163,7 @@ impl App {
|
||||
let should_check = WindowsSandboxLevel::from_config(&app.config)
|
||||
!= WindowsSandboxLevel::Disabled
|
||||
&& matches!(
|
||||
app.config.sandbox_policy.get(),
|
||||
app.config.permissions.sandbox_policy.get(),
|
||||
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly { .. }
|
||||
)
|
||||
@@ -1170,7 +1177,7 @@ impl App {
|
||||
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
|
||||
let tx = app.app_event_tx.clone();
|
||||
let logs_base_dir = app.config.codex_home.clone();
|
||||
let sandbox_policy = app.config.sandbox_policy.get().clone();
|
||||
let sandbox_policy = app.config.permissions.sandbox_policy.get().clone();
|
||||
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx);
|
||||
}
|
||||
}
|
||||
@@ -1598,19 +1605,24 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::OpenAppLink {
|
||||
app_id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
} => {
|
||||
self.chat_widget.open_app_link_view(
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
);
|
||||
self.chat_widget
|
||||
.open_app_link_view(crate::bottom_pane::AppLinkViewParams {
|
||||
app_id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
});
|
||||
}
|
||||
AppEvent::OpenUrlInBrowser { url } => {
|
||||
self.open_url_in_browser(url);
|
||||
@@ -1826,6 +1838,63 @@ impl App {
|
||||
let _ = preset;
|
||||
}
|
||||
}
|
||||
AppEvent::BeginWindowsSandboxGrantReadRoot { path } => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.chat_widget
|
||||
.add_to_history(history_cell::new_info_event(
|
||||
format!("Granting sandbox read access to {path} ..."),
|
||||
None,
|
||||
));
|
||||
|
||||
let policy = self.config.permissions.sandbox_policy.get().clone();
|
||||
let policy_cwd = self.config.cwd.clone();
|
||||
let command_cwd = self.config.cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> =
|
||||
std::env::vars().collect();
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
let tx = self.app_event_tx.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let requested_path = PathBuf::from(path);
|
||||
let event = match codex_core::windows_sandbox_read_grants::grant_read_root_non_elevated(
|
||||
&policy,
|
||||
policy_cwd.as_path(),
|
||||
command_cwd.as_path(),
|
||||
&env_map,
|
||||
codex_home.as_path(),
|
||||
requested_path.as_path(),
|
||||
) {
|
||||
Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted {
|
||||
path: canonical_path,
|
||||
error: None,
|
||||
},
|
||||
Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted {
|
||||
path: requested_path,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
};
|
||||
tx.send(event);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = path;
|
||||
}
|
||||
}
|
||||
AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error {
|
||||
Some(err) => {
|
||||
self.chat_widget
|
||||
.add_to_history(history_cell::new_error_event(format!("Error: {err}")));
|
||||
}
|
||||
None => {
|
||||
self.chat_widget
|
||||
.add_to_history(history_cell::new_info_event(
|
||||
format!("Sandbox read access granted for {}", path.display()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
},
|
||||
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -1856,8 +1925,9 @@ impl App {
|
||||
self.config.set_windows_sandbox_enabled(true);
|
||||
self.config.set_windows_elevated_sandbox_enabled(false);
|
||||
}
|
||||
self.chat_widget
|
||||
.set_windows_sandbox_mode(self.config.windows_sandbox_mode);
|
||||
self.chat_widget.set_windows_sandbox_mode(
|
||||
self.config.permissions.windows_sandbox_mode,
|
||||
);
|
||||
let windows_sandbox_level =
|
||||
WindowsSandboxLevel::from_config(&self.config);
|
||||
if let Some((sample_paths, extra_count, failed_scan)) =
|
||||
@@ -2003,7 +2073,7 @@ impl App {
|
||||
}
|
||||
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
||||
self.runtime_approval_policy_override = Some(policy);
|
||||
if let Err(err) = self.config.approval_policy.set(policy) {
|
||||
if let Err(err) = self.config.permissions.approval_policy.set(policy) {
|
||||
tracing::warn!(%err, "failed to set approval policy on app config");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to set approval policy: {err}"));
|
||||
@@ -2020,7 +2090,7 @@ impl App {
|
||||
);
|
||||
let policy_for_chat = policy.clone();
|
||||
|
||||
if let Err(err) = self.config.sandbox_policy.set(policy) {
|
||||
if let Err(err) = self.config.permissions.sandbox_policy.set(policy) {
|
||||
tracing::warn!(%err, "failed to set sandbox policy on app config");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to set sandbox policy: {err}"));
|
||||
@@ -2033,7 +2103,7 @@ impl App {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.runtime_sandbox_policy_override =
|
||||
Some(self.config.sandbox_policy.get().clone());
|
||||
Some(self.config.permissions.sandbox_policy.get().clone());
|
||||
|
||||
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -2054,7 +2124,7 @@ impl App {
|
||||
std::env::vars().collect();
|
||||
let tx = self.app_event_tx.clone();
|
||||
let logs_base_dir = self.config.codex_home.clone();
|
||||
let sandbox_policy = self.config.sandbox_policy.get().clone();
|
||||
let sandbox_policy = self.config.permissions.sandbox_policy.get().clone();
|
||||
Self::spawn_world_writable_scan(
|
||||
cwd,
|
||||
env_map,
|
||||
@@ -2228,6 +2298,12 @@ impl App {
|
||||
{
|
||||
Ok(()) => {
|
||||
self.chat_widget.update_skill_enabled(path.clone(), enabled);
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after skill toggle"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = path.display();
|
||||
@@ -2237,6 +2313,55 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SetAppEnabled { id, enabled } => {
|
||||
let edits = if enabled {
|
||||
vec![
|
||||
ConfigEdit::ClearPath {
|
||||
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
|
||||
},
|
||||
ConfigEdit::ClearPath {
|
||||
segments: vec![
|
||||
"apps".to_string(),
|
||||
id.clone(),
|
||||
"disabled_reason".to_string(),
|
||||
],
|
||||
},
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
|
||||
value: false.into(),
|
||||
},
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"apps".to_string(),
|
||||
id.clone(),
|
||||
"disabled_reason".to_string(),
|
||||
],
|
||||
value: "user".into(),
|
||||
},
|
||||
]
|
||||
};
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.chat_widget.update_connector_enabled(&id, enabled);
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, "failed to refresh config after app toggle");
|
||||
}
|
||||
self.chat_widget.submit_op(Op::ReloadUserConfig);
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to update app config for {id}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::OpenPermissionsPopup => {
|
||||
self.chat_widget.open_permissions_popup();
|
||||
}
|
||||
@@ -2871,6 +2996,19 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option<bool> {
|
||||
config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("apps"))
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|apps| apps.get(app_id))
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|app| app.get("enabled"))
|
||||
.and_then(TomlValue::as_bool)
|
||||
}
|
||||
|
||||
fn all_model_presets() -> Vec<ModelPreset> {
|
||||
codex_core::test_support::all_model_presets().clone()
|
||||
}
|
||||
@@ -3050,6 +3188,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let codex_home = tempdir()?;
|
||||
app.config.codex_home = codex_home.path().to_path_buf();
|
||||
let app_id = "connector_1".to_string();
|
||||
|
||||
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
|
||||
|
||||
ConfigEditsBuilder::new(&app.config.codex_home)
|
||||
.with_edits([
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()],
|
||||
value: false.into(),
|
||||
},
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"apps".to_string(),
|
||||
app_id.clone(),
|
||||
"disabled_reason".to_string(),
|
||||
],
|
||||
value: "user".into(),
|
||||
},
|
||||
])
|
||||
.apply()
|
||||
.await
|
||||
.expect("persist app toggle");
|
||||
|
||||
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
|
||||
|
||||
app.refresh_in_memory_config_from_disk().await?;
|
||||
|
||||
assert_eq!(
|
||||
app_enabled_in_effective_config(&app.config, &app_id),
|
||||
Some(false)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
||||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
@@ -107,11 +107,13 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Open the app link view in the bottom pane.
|
||||
OpenAppLink {
|
||||
app_id: String,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
is_enabled: bool,
|
||||
},
|
||||
|
||||
/// Open the provided URL in the user's browser.
|
||||
@@ -218,6 +220,19 @@ pub(crate) enum AppEvent {
|
||||
preset: ApprovalPreset,
|
||||
},
|
||||
|
||||
/// Begin a non-elevated grant of read access for an additional directory.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
BeginWindowsSandboxGrantReadRoot {
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Result of attempting to grant read access for an additional directory.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
WindowsSandboxGrantReadRootCompleted {
|
||||
path: PathBuf,
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
/// Enable the Windows sandbox feature and switch to Agent mode.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
EnableWindowsSandboxForAgentMode {
|
||||
@@ -284,6 +299,12 @@ pub(crate) enum AppEvent {
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Enable or disable an app by connector ID.
|
||||
SetAppEnabled {
|
||||
id: String,
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Notify that the manage skills popup was closed.
|
||||
ManageSkillsClosed,
|
||||
|
||||
|
||||
@@ -32,12 +32,24 @@ enum AppLinkScreen {
|
||||
InstallConfirmation,
|
||||
}
|
||||
|
||||
pub(crate) struct AppLinkViewParams {
|
||||
pub(crate) app_id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) instructions: String,
|
||||
pub(crate) url: String,
|
||||
pub(crate) is_installed: bool,
|
||||
pub(crate) is_enabled: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct AppLinkView {
|
||||
app_id: String,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
is_enabled: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
screen: AppLinkScreen,
|
||||
selected_action: usize,
|
||||
@@ -45,20 +57,24 @@ pub(crate) struct AppLinkView {
|
||||
}
|
||||
|
||||
impl AppLinkView {
|
||||
pub(crate) fn new(
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Self {
|
||||
Self {
|
||||
pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
let AppLinkViewParams {
|
||||
app_id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
} = params;
|
||||
Self {
|
||||
app_id,
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
app_event_tx,
|
||||
screen: AppLinkScreen::Link,
|
||||
selected_action: 0,
|
||||
@@ -66,16 +82,24 @@ impl AppLinkView {
|
||||
}
|
||||
}
|
||||
|
||||
fn action_labels(&self) -> [&'static str; 2] {
|
||||
fn action_labels(&self) -> Vec<&'static str> {
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => {
|
||||
if self.is_installed {
|
||||
["Manage on ChatGPT", "Back"]
|
||||
vec![
|
||||
"Manage on ChatGPT",
|
||||
if self.is_enabled {
|
||||
"Disable app"
|
||||
} else {
|
||||
"Enable app"
|
||||
},
|
||||
"Back",
|
||||
]
|
||||
} else {
|
||||
["Install on ChatGPT", "Back"]
|
||||
vec!["Install on ChatGPT", "Back"]
|
||||
}
|
||||
}
|
||||
AppLinkScreen::InstallConfirmation => ["I already Installed it", "Back"],
|
||||
AppLinkScreen::InstallConfirmation => vec!["I already Installed it", "Back"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,42 +111,47 @@ impl AppLinkView {
|
||||
self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
|
||||
}
|
||||
|
||||
fn handle_primary_action(&mut self) {
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => {
|
||||
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: self.url.clone(),
|
||||
});
|
||||
if !self.is_installed {
|
||||
self.screen = AppLinkScreen::InstallConfirmation;
|
||||
self.selected_action = 0;
|
||||
}
|
||||
}
|
||||
AppLinkScreen::InstallConfirmation => {
|
||||
self.app_event_tx.send(AppEvent::RefreshConnectors {
|
||||
force_refetch: true,
|
||||
});
|
||||
self.complete = true;
|
||||
}
|
||||
fn open_chatgpt_link(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: self.url.clone(),
|
||||
});
|
||||
if !self.is_installed {
|
||||
self.screen = AppLinkScreen::InstallConfirmation;
|
||||
self.selected_action = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_secondary_action(&mut self) {
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => {
|
||||
self.complete = true;
|
||||
}
|
||||
AppLinkScreen::InstallConfirmation => {
|
||||
self.screen = AppLinkScreen::Link;
|
||||
self.selected_action = 0;
|
||||
}
|
||||
}
|
||||
fn refresh_connectors_and_close(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::RefreshConnectors {
|
||||
force_refetch: true,
|
||||
});
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
fn back_to_link_screen(&mut self) {
|
||||
self.screen = AppLinkScreen::Link;
|
||||
self.selected_action = 0;
|
||||
}
|
||||
|
||||
fn toggle_enabled(&mut self) {
|
||||
self.is_enabled = !self.is_enabled;
|
||||
self.app_event_tx.send(AppEvent::SetAppEnabled {
|
||||
id: self.app_id.clone(),
|
||||
enabled: self.is_enabled,
|
||||
});
|
||||
}
|
||||
|
||||
fn activate_selected_action(&mut self) {
|
||||
match self.selected_action {
|
||||
0 => self.handle_primary_action(),
|
||||
_ => self.handle_secondary_action(),
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => match self.selected_action {
|
||||
0 => self.open_chatgpt_link(),
|
||||
1 if self.is_installed => self.toggle_enabled(),
|
||||
_ => self.complete = true,
|
||||
},
|
||||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||||
0 => self.refresh_connectors_and_close(),
|
||||
_ => self.back_to_link_screen(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,20 +337,19 @@ impl BottomPaneView for AppLinkView {
|
||||
..
|
||||
} => self.move_selection_next(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('1'),
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.selected_action = 0;
|
||||
self.activate_selected_action();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('2'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.selected_action = 1;
|
||||
self.activate_selected_action();
|
||||
if let Some(index) = c
|
||||
.to_digit(10)
|
||||
.and_then(|digit| digit.checked_sub(1))
|
||||
.map(|index| index as usize)
|
||||
&& index < self.action_labels().len()
|
||||
{
|
||||
self.selected_action = index;
|
||||
self.activate_selected_action();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
@@ -402,3 +430,67 @@ impl crate::render::renderable::Renderable for AppLinkView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn installed_app_has_toggle_action() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_1".to_string(),
|
||||
title: "Notion".to_string(),
|
||||
description: None,
|
||||
instructions: "Manage app".to_string(),
|
||||
url: "https://example.test/notion".to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
view.action_labels(),
|
||||
vec!["Manage on ChatGPT", "Disable app", "Back"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_action_sends_set_app_enabled_and_updates_label() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_1".to_string(),
|
||||
title: "Notion".to_string(),
|
||||
description: None,
|
||||
instructions: "Manage app".to_string(),
|
||||
url: "https://example.test/notion".to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SetAppEnabled { id, enabled }) => {
|
||||
assert_eq!(id, "connector_1");
|
||||
assert!(!enabled);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
view.action_labels(),
|
||||
vec!["Manage on ChatGPT", "Enable app", "Back"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3099,7 +3099,7 @@ impl ChatComposer {
|
||||
&& let Some(snapshot) = self.connectors_snapshot.as_ref()
|
||||
{
|
||||
for connector in &snapshot.connectors {
|
||||
if !connector.is_accessible {
|
||||
if !connector.is_accessible || !connector.is_enabled {
|
||||
continue;
|
||||
}
|
||||
let display_name = connectors::connector_display_label(connector);
|
||||
@@ -4342,6 +4342,7 @@ mod tests {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}];
|
||||
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
|
||||
|
||||
@@ -4355,6 +4356,36 @@ mod tests {
|
||||
assert_eq!(mention.path, Some("app://connector_1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_connectors_enabled(true);
|
||||
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
let connectors = vec![AppInfo {
|
||||
id: "connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
}];
|
||||
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
|
||||
|
||||
assert!(matches!(composer.active_popup, ActivePopup::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_overlay_persists_while_task_running() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -44,6 +44,7 @@ mod multi_select_picker;
|
||||
mod request_user_input;
|
||||
mod status_line_setup;
|
||||
pub(crate) use app_link_view::AppLinkView;
|
||||
pub(crate) use app_link_view::AppLinkViewParams;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
pub(crate) use approval_overlay::ApprovalRequest;
|
||||
pub(crate) use request_user_input::RequestUserInputOverlay;
|
||||
|
||||
+117
-47
@@ -1141,22 +1141,8 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn open_app_link_view(
|
||||
&mut self,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
instructions: String,
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
) {
|
||||
let view = crate::bottom_pane::AppLinkView::new(
|
||||
title,
|
||||
description,
|
||||
instructions,
|
||||
url,
|
||||
is_installed,
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
pub(crate) fn open_app_link_view(&mut self, params: crate::bottom_pane::AppLinkViewParams) {
|
||||
let view = crate::bottom_pane::AppLinkView::new(params, self.app_event_tx.clone());
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -3323,7 +3309,12 @@ impl ChatWidget {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = self.config.approval_policy.can_set(&preset.approval) {
|
||||
if let Err(err) = self
|
||||
.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.can_set(&preset.approval)
|
||||
{
|
||||
self.add_error_message(err.to_string());
|
||||
return;
|
||||
}
|
||||
@@ -3342,6 +3333,11 @@ impl ChatWidget {
|
||||
// Not supported; on non-Windows this command should never be reachable.
|
||||
};
|
||||
}
|
||||
SlashCommand::SandboxReadRoot => {
|
||||
self.add_error_message(
|
||||
"Usage: /sandbox-add-read-dir <absolute-directory-path>".to_string(),
|
||||
);
|
||||
}
|
||||
SlashCommand::Experimental => {
|
||||
self.open_experimental_popup();
|
||||
}
|
||||
@@ -3544,6 +3540,18 @@ impl ChatWidget {
|
||||
});
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
||||
let Some((prepared_args, _prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
|
||||
path: prepared_args,
|
||||
});
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
_ => self.dispatch_command(cmd),
|
||||
}
|
||||
}
|
||||
@@ -3759,7 +3767,7 @@ impl ChatWidget {
|
||||
if !selected_app_ids.insert(app_id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(app) = apps.iter().find(|app| app.id == app_id) {
|
||||
if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) {
|
||||
items.push(UserInput::Mention {
|
||||
name: app.name.clone(),
|
||||
path: binding.path.clone(),
|
||||
@@ -3797,8 +3805,8 @@ impl ChatWidget {
|
||||
let op = Op::UserTurn {
|
||||
items,
|
||||
cwd: self.config.cwd.clone(),
|
||||
approval_policy: self.config.approval_policy.value(),
|
||||
sandbox_policy: self.config.sandbox_policy.get().clone(),
|
||||
approval_policy: self.config.permissions.approval_policy.value(),
|
||||
sandbox_policy: self.config.permissions.sandbox_policy.get().clone(),
|
||||
model: effective_mode.model().to_string(),
|
||||
effort: effective_mode.reasoning_effort(),
|
||||
summary: self.config.model_reasoning_summary,
|
||||
@@ -5255,8 +5263,8 @@ impl ChatWidget {
|
||||
/// Open a popup to choose the permissions mode (approval policy + sandbox policy).
|
||||
pub(crate) fn open_permissions_popup(&mut self) {
|
||||
let include_read_only = cfg!(target_os = "windows");
|
||||
let current_approval = self.config.approval_policy.value();
|
||||
let current_sandbox = self.config.sandbox_policy.get();
|
||||
let current_approval = self.config.permissions.approval_policy.value();
|
||||
let current_sandbox = self.config.permissions.sandbox_policy.get();
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||
|
||||
@@ -5284,7 +5292,12 @@ impl ChatWidget {
|
||||
preset.label.to_string()
|
||||
};
|
||||
let description = Some(preset.description.replace(" (Identical to Agent mode)", ""));
|
||||
let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) {
|
||||
let disabled_reason = match self
|
||||
.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.can_set(&preset.approval)
|
||||
{
|
||||
Ok(()) => None,
|
||||
Err(err) => Some(err.to_string()),
|
||||
};
|
||||
@@ -5445,7 +5458,7 @@ impl ChatWidget {
|
||||
self.config.codex_home.as_path(),
|
||||
cwd.as_path(),
|
||||
&env_map,
|
||||
self.config.sandbox_policy.get(),
|
||||
self.config.permissions.sandbox_policy.get(),
|
||||
Some(self.config.codex_home.as_path()),
|
||||
) {
|
||||
Ok(_) => None,
|
||||
@@ -5552,7 +5565,7 @@ impl ChatWidget {
|
||||
let mode_label = preset
|
||||
.as_ref()
|
||||
.map(|p| describe_policy(&p.sandbox))
|
||||
.unwrap_or_else(|| describe_policy(self.config.sandbox_policy.get()));
|
||||
.unwrap_or_else(|| describe_policy(self.config.permissions.sandbox_policy.get()));
|
||||
let info_line = if failed_scan {
|
||||
Line::from(vec![
|
||||
"We couldn't complete the world-writable scan, so protections cannot be verified. "
|
||||
@@ -5895,7 +5908,7 @@ impl ChatWidget {
|
||||
|
||||
/// Set the approval policy in the widget's config copy.
|
||||
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
||||
if let Err(err) = self.config.approval_policy.set(policy) {
|
||||
if let Err(err) = self.config.permissions.approval_policy.set(policy) {
|
||||
tracing::warn!(%err, "failed to set approval_policy on chat config");
|
||||
}
|
||||
}
|
||||
@@ -5903,13 +5916,13 @@ impl ChatWidget {
|
||||
/// Set the sandbox policy in the widget's config copy.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> {
|
||||
self.config.sandbox_policy.set(policy)?;
|
||||
self.config.permissions.sandbox_policy.set(policy)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option<WindowsSandboxModeToml>) {
|
||||
self.config.windows_sandbox_mode = mode;
|
||||
self.config.permissions.windows_sandbox_mode = mode;
|
||||
#[cfg(target_os = "windows")]
|
||||
self.bottom_pane.set_windows_degraded_sandbox_active(
|
||||
codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
||||
@@ -6387,6 +6400,7 @@ impl ChatWidget {
|
||||
let connector_title = connector_label.clone();
|
||||
let link_description = Self::connector_description(connector);
|
||||
let description = Self::connector_brief_description(connector);
|
||||
let status_label = Self::connector_status_label(connector);
|
||||
let search_value = format!("{connector_label} {}", connector.id);
|
||||
let mut item = SelectionItem {
|
||||
name: connector_label,
|
||||
@@ -6395,42 +6409,47 @@ impl ChatWidget {
|
||||
..Default::default()
|
||||
};
|
||||
let is_installed = connector.is_accessible;
|
||||
let (selected_label, missing_label, instructions) = if connector.is_accessible {
|
||||
(
|
||||
"Press Enter to view the app link.",
|
||||
"App link unavailable.",
|
||||
"Manage this app in your browser.",
|
||||
let selected_label = if is_installed {
|
||||
format!(
|
||||
"{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app."
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Press Enter to view the install link.",
|
||||
"Install link unavailable.",
|
||||
"Install this app in your browser, then reload Codex.",
|
||||
)
|
||||
format!("{status_label}. Press Enter to open the app page to install this app.")
|
||||
};
|
||||
let missing_label = format!("{status_label}. App link unavailable.");
|
||||
let instructions = if connector.is_accessible {
|
||||
"Manage this app in your browser."
|
||||
} else {
|
||||
"Install this app in your browser, then reload Codex."
|
||||
};
|
||||
if let Some(install_url) = connector.install_url.clone() {
|
||||
let app_id = connector.id.clone();
|
||||
let is_enabled = connector.is_enabled;
|
||||
let title = connector_title.clone();
|
||||
let instructions = instructions.to_string();
|
||||
let description = link_description.clone();
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenAppLink {
|
||||
app_id: app_id.clone(),
|
||||
title: title.clone(),
|
||||
description: description.clone(),
|
||||
instructions: instructions.clone(),
|
||||
url: install_url.clone(),
|
||||
is_installed,
|
||||
is_enabled,
|
||||
});
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some(selected_label.to_string());
|
||||
item.selected_description = Some(selected_label);
|
||||
} else {
|
||||
let missing_label_for_action = missing_label.clone();
|
||||
item.actions = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(missing_label.to_string(), None),
|
||||
history_cell::new_info_event(missing_label_for_action.clone(), None),
|
||||
)));
|
||||
})];
|
||||
item.dismiss_on_select = true;
|
||||
item.selected_description = Some(missing_label.to_string());
|
||||
item.selected_description = Some(missing_label);
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
@@ -6463,17 +6482,25 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn connector_brief_description(connector: &connectors::AppInfo) -> String {
|
||||
let status_label = if connector.is_accessible {
|
||||
"Connected"
|
||||
} else {
|
||||
"Can be installed"
|
||||
};
|
||||
let status_label = Self::connector_status_label(connector);
|
||||
match Self::connector_description(connector) {
|
||||
Some(description) => format!("{status_label} · {description}"),
|
||||
None => status_label.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_status_label(connector: &connectors::AppInfo) -> &'static str {
|
||||
if connector.is_accessible {
|
||||
if connector.is_enabled {
|
||||
"Installed"
|
||||
} else {
|
||||
"Installed · Disabled"
|
||||
}
|
||||
} else {
|
||||
"Can be installed"
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_description(connector: &connectors::AppInfo) -> Option<String> {
|
||||
connector
|
||||
.description
|
||||
@@ -6700,7 +6727,27 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(snapshot) => {
|
||||
Ok(mut snapshot) => {
|
||||
if !is_final {
|
||||
snapshot.connectors = connectors::merge_connectors_with_accessible(
|
||||
Vec::new(),
|
||||
snapshot.connectors,
|
||||
);
|
||||
}
|
||||
snapshot.connectors =
|
||||
connectors::with_app_enabled_state(snapshot.connectors, &self.config);
|
||||
if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors_cache {
|
||||
let enabled_by_id: HashMap<&str, bool> = existing_snapshot
|
||||
.connectors
|
||||
.iter()
|
||||
.map(|connector| (connector.id.as_str(), connector.is_enabled))
|
||||
.collect();
|
||||
for connector in &mut snapshot.connectors {
|
||||
if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) {
|
||||
connector.is_enabled = *is_enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.refresh_connectors_popup_if_open(&snapshot.connectors);
|
||||
if is_final || !matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) {
|
||||
self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone());
|
||||
@@ -6719,6 +6766,29 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) {
|
||||
let ConnectorsCacheState::Ready(mut snapshot) = self.connectors_cache.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
for connector in &mut snapshot.connectors {
|
||||
if connector.id == connector_id {
|
||||
changed = connector.is_enabled != enabled;
|
||||
connector.is_enabled = enabled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.refresh_connectors_popup_if_open(&snapshot.connectors);
|
||||
self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone());
|
||||
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
|
||||
@@ -267,12 +267,12 @@ pub(crate) fn find_app_mentions(
|
||||
}
|
||||
|
||||
let mut slug_counts: HashMap<String, usize> = HashMap::new();
|
||||
for app in apps {
|
||||
for app in apps.iter().filter(|app| app.is_enabled) {
|
||||
let slug = connector_mention_slug(app);
|
||||
*slug_counts.entry(slug).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for app in apps {
|
||||
for app in apps.iter().filter(|app| app.is_enabled) {
|
||||
let slug = connector_mention_slug(app);
|
||||
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
|
||||
if mentions.names.contains(&slug)
|
||||
@@ -285,7 +285,7 @@ pub(crate) fn find_app_mentions(
|
||||
}
|
||||
|
||||
apps.iter()
|
||||
.filter(|app| selected_ids.contains(&app.id))
|
||||
.filter(|app| app.is_enabled && selected_ids.contains(&app.id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -3878,6 +3878,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
false,
|
||||
@@ -3893,6 +3894,10 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
|
||||
before.contains("Installed 1 of 1 available apps."),
|
||||
"expected initial apps popup snapshot, got:\n{before}"
|
||||
);
|
||||
assert!(
|
||||
before.contains("Installed. Press Enter to open the app page"),
|
||||
"expected selected app description to explain the app page action, got:\n{before}"
|
||||
);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
@@ -3906,6 +3911,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_2".to_string(),
|
||||
@@ -3916,6 +3922,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/linear".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -3949,6 +3956,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_2".to_string(),
|
||||
@@ -3959,6 +3967,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/linear".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
chat.on_connectors_loaded(
|
||||
@@ -3979,6 +3988,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
false,
|
||||
@@ -3998,6 +4008,265 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
let full_connectors = vec![
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "unit_test_connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "unit_test_connector_2".to_string(),
|
||||
name: "Linear".to_string(),
|
||||
description: Some("Project tracking".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/linear".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
},
|
||||
];
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: full_connectors.clone(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
chat.add_connectors_output();
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "unit_test_connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_openai_hidden".to_string(),
|
||||
name: "Hidden OpenAI".to_string(),
|
||||
description: Some("Should be filtered".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/hidden-openai".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
&chat.connectors_cache,
|
||||
ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Installed 1 of 1 available apps."),
|
||||
"expected partial refresh popup to use filtered connectors, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
!popup.contains("Hidden OpenAI"),
|
||||
"expected disallowed connector to be filtered from partial refresh popup, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
}],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
chat.add_connectors_output();
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Installed · Disabled. Press Enter to open the app page"),
|
||||
"expected selected app description to include disabled status, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
popup.contains("enable/disable this app."),
|
||||
"expected selected app description to mention enable/disable action, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_initial_load_applies_enabled_state_from_config() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let config_toml_path =
|
||||
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
|
||||
let user_config = toml::from_str::<TomlValue>(
|
||||
"[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n",
|
||||
)
|
||||
.expect("apps config");
|
||||
chat.config.config_layer_stack = chat
|
||||
.config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
&chat.connectors_cache,
|
||||
ConnectorsCacheState::Ready(snapshot)
|
||||
if snapshot
|
||||
.connectors
|
||||
.iter()
|
||||
.find(|connector| connector.id == "connector_1")
|
||||
.is_some_and(|connector| !connector.is_enabled)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_refresh_preserves_toggled_enabled_state() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
chat.update_connector_enabled("connector_1", false);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_1".to_string(),
|
||||
name: "Notion".to_string(),
|
||||
description: Some("Workspace docs".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/notion".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
&chat.connectors_cache,
|
||||
ConnectorsCacheState::Ready(snapshot)
|
||||
if snapshot
|
||||
.connectors
|
||||
.iter()
|
||||
.find(|connector| connector.id == "connector_1")
|
||||
.is_some_and(|connector| !connector.is_enabled)
|
||||
);
|
||||
|
||||
chat.add_connectors_output();
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Installed · Disabled. Press Enter to open the app page"),
|
||||
"expected disabled status to persist after reload, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_popup_for_not_installed_app_uses_install_only_selected_description() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.features.enable(Feature::Apps);
|
||||
chat.bottom_pane.set_connectors_enabled(true);
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![codex_chatgpt::connectors::AppInfo {
|
||||
id: "connector_2".to_string(),
|
||||
name: "Linear".to_string(),
|
||||
description: Some("Project tracking".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://example.test/linear".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
}],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
chat.add_connectors_output();
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Can be installed. Press Enter to open the app page to install"),
|
||||
"expected selected app description to be install-only for not-installed apps, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
!popup.contains("enable/disable this app."),
|
||||
"did not expect enable/disable text for not-installed apps, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_features_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
@@ -4510,7 +4779,7 @@ async fn disabled_slash_command_while_task_running_snapshot() {
|
||||
async fn approvals_popup_shows_disabled_presets() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.config.approval_policy =
|
||||
chat.config.permissions.approval_policy =
|
||||
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
|
||||
AskForApproval::OnRequest => Ok(()),
|
||||
_ => Err(invalid_value(
|
||||
@@ -4546,7 +4815,7 @@ async fn approvals_popup_shows_disabled_presets() {
|
||||
async fn approvals_popup_navigation_skips_disabled() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.config.approval_policy =
|
||||
chat.config.permissions.approval_policy =
|
||||
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
|
||||
AskForApproval::OnRequest => Ok(()),
|
||||
_ => Err(invalid_value(candidate.to_string(), "[on-request]")),
|
||||
@@ -4623,7 +4892,10 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
|
||||
// Build a chat widget with manual channels to avoid spawning the agent.
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
// Ensure policy allows surfacing approvals explicitly (not strictly required for direct event).
|
||||
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||
chat.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)?;
|
||||
// Inject an exec approval request to display the approval modal.
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-cmd".into(),
|
||||
@@ -4678,7 +4950,10 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
|
||||
#[tokio::test]
|
||||
async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||
chat.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)?;
|
||||
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-cmd-noreason".into(),
|
||||
@@ -4720,7 +4995,10 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
|
||||
async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
|
||||
-> anyhow::Result<()> {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||
chat.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)?;
|
||||
|
||||
let script = "python - <<'PY'\nprint('hello')\nPY".to_string();
|
||||
let command = vec!["bash".into(), "-lc".into(), script];
|
||||
@@ -4760,7 +5038,10 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
|
||||
#[tokio::test]
|
||||
async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||
chat.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)?;
|
||||
|
||||
// Build a small changeset and a reason/grant_root to exercise the prompt text.
|
||||
let mut changes = HashMap::new();
|
||||
@@ -5529,7 +5810,10 @@ async fn apply_patch_full_flow_integration_like() {
|
||||
async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
// Ensure approval policy is untrusted (OnRequest)
|
||||
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||
chat.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)?;
|
||||
|
||||
// Simulate a patch approval request from backend
|
||||
let mut changes = HashMap::new();
|
||||
@@ -5577,7 +5861,10 @@ async fn apply_patch_request_shows_diff_summary() -> anyhow::Result<()> {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
// Ensure we are in OnRequest so an approval is surfaced
|
||||
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
|
||||
chat.config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)?;
|
||||
|
||||
// Simulate backend asking to apply a patch adding two lines to README.md
|
||||
let mut changes = HashMap::new();
|
||||
|
||||
@@ -33,6 +33,7 @@ pub(crate) fn new_debug_config_output(
|
||||
http_addr,
|
||||
socks_addr,
|
||||
config
|
||||
.permissions
|
||||
.network
|
||||
.as_ref()
|
||||
.is_some_and(codex_core::config::NetworkProxySpec::socks_enabled),
|
||||
|
||||
@@ -290,7 +290,9 @@ pub async fn run_main(
|
||||
.await;
|
||||
set_default_client_residency_requirement(config.enforce_residency.value());
|
||||
|
||||
if let Some(warning) = add_dir_warning_message(&cli.add_dir, config.sandbox_policy.get()) {
|
||||
if let Some(warning) =
|
||||
add_dir_warning_message(&cli.add_dir, config.permissions.sandbox_policy.get())
|
||||
{
|
||||
#[allow(clippy::print_stderr)]
|
||||
{
|
||||
eprintln!("Error adding directories: {warning}");
|
||||
@@ -1004,8 +1006,9 @@ mod tests {
|
||||
TurnContextItem {
|
||||
turn_id: None,
|
||||
cwd,
|
||||
approval_policy: config.approval_policy.value(),
|
||||
sandbox_policy: config.sandbox_policy.get().clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
model,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
@@ -1123,7 +1126,7 @@ trust_level = "untrusted"
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
trusted_config.approval_policy.value(),
|
||||
trusted_config.permissions.approval_policy.value(),
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
|
||||
@@ -1137,7 +1140,7 @@ trust_level = "untrusted"
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
untrusted_config.approval_policy.value(),
|
||||
untrusted_config.permissions.approval_policy.value(),
|
||||
AskForApproval::UnlessTrusted
|
||||
);
|
||||
Ok(())
|
||||
|
||||
@@ -17,6 +17,8 @@ pub enum SlashCommand {
|
||||
Permissions,
|
||||
#[strum(serialize = "setup-default-sandbox")]
|
||||
ElevateSandbox,
|
||||
#[strum(serialize = "sandbox-add-read-dir")]
|
||||
SandboxReadRoot,
|
||||
Experimental,
|
||||
Skills,
|
||||
Review,
|
||||
@@ -84,7 +86,10 @@ impl SlashCommand {
|
||||
SlashCommand::Agent => "switch the active agent thread",
|
||||
SlashCommand::Approvals => "choose what Codex is allowed to do",
|
||||
SlashCommand::Permissions => "choose what Codex is allowed to do",
|
||||
SlashCommand::ElevateSandbox => "set up default agent sandbox",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::SandboxReadRoot => {
|
||||
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
|
||||
}
|
||||
SlashCommand::Experimental => "toggle experimental features",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Apps => "manage apps",
|
||||
@@ -104,7 +109,10 @@ impl SlashCommand {
|
||||
pub fn supports_inline_args(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan
|
||||
SlashCommand::Review
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::SandboxReadRoot
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,6 +130,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Permissions
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::SandboxReadRoot
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Plan
|
||||
@@ -151,6 +160,7 @@ impl SlashCommand {
|
||||
|
||||
fn is_visible(self) -> bool {
|
||||
match self {
|
||||
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
|
||||
SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions),
|
||||
_ => true,
|
||||
}
|
||||
|
||||
@@ -168,10 +168,13 @@ impl StatusHistoryCell {
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", model_name.to_string()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", config.approval_policy.value().to_string()),
|
||||
(
|
||||
"approval",
|
||||
config.permissions.approval_policy.value().to_string(),
|
||||
),
|
||||
(
|
||||
"sandbox",
|
||||
summarize_sandbox_policy(config.sandbox_policy.get()),
|
||||
summarize_sandbox_policy(config.permissions.sandbox_policy.get()),
|
||||
),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses {
|
||||
@@ -191,7 +194,7 @@ impl StatusHistoryCell {
|
||||
.find(|(k, _)| *k == "approval")
|
||||
.map(|(_, v)| v.clone())
|
||||
.unwrap_or_else(|| "<unknown>".to_string());
|
||||
let sandbox = match config.sandbox_policy.get() {
|
||||
let sandbox = match config.permissions.sandbox_policy.get() {
|
||||
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
|
||||
SandboxPolicy::ReadOnly { .. } => "read-only".to_string(),
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
@@ -207,12 +210,13 @@ impl StatusHistoryCell {
|
||||
}
|
||||
}
|
||||
};
|
||||
let permissions = if config.approval_policy.value() == AskForApproval::OnRequest
|
||||
&& *config.sandbox_policy.get() == SandboxPolicy::new_workspace_write_policy()
|
||||
let permissions = if config.permissions.approval_policy.value() == AskForApproval::OnRequest
|
||||
&& *config.permissions.sandbox_policy.get()
|
||||
== SandboxPolicy::new_workspace_write_policy()
|
||||
{
|
||||
"Default".to_string()
|
||||
} else if config.approval_policy.value() == AskForApproval::Never
|
||||
&& *config.sandbox_policy.get() == SandboxPolicy::DangerFullAccess
|
||||
} else if config.permissions.approval_policy.value() == AskForApproval::Never
|
||||
&& *config.permissions.sandbox_policy.get() == SandboxPolicy::DangerFullAccess
|
||||
{
|
||||
"Full Access".to_string()
|
||||
} else {
|
||||
|
||||
@@ -98,6 +98,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.model_reasoning_summary = ReasoningSummary::Detailed;
|
||||
config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
@@ -177,10 +178,12 @@ async fn status_permissions_non_default_workspace_write_is_custom() {
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.expect("set approval policy");
|
||||
config
|
||||
.permissions
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
|
||||
@@ -12,9 +12,10 @@ pub enum ApprovalModeCliArg {
|
||||
/// is not in the "trusted" set.
|
||||
Untrusted,
|
||||
|
||||
/// Run all commands without asking for user approval.
|
||||
/// DEPRECATED: Run all commands without asking for user approval.
|
||||
/// Only asks for approval if a command fails to execute, in which case it
|
||||
/// will escalate to the user to ask for un-sandboxed execution.
|
||||
/// Prefer `on-request` for interactive runs or `never` for non-interactive runs.
|
||||
OnFailure,
|
||||
|
||||
/// The model decides when to ask the user for approval.
|
||||
|
||||
@@ -9,10 +9,13 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", model.to_string()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", config.approval_policy.value().to_string()),
|
||||
(
|
||||
"approval",
|
||||
config.permissions.approval_policy.value().to_string(),
|
||||
),
|
||||
(
|
||||
"sandbox",
|
||||
summarize_sandbox_policy(config.sandbox_policy.get()),
|
||||
summarize_sandbox_policy(config.permissions.sandbox_policy.get()),
|
||||
),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses {
|
||||
|
||||
@@ -83,6 +83,8 @@ pub use setup::run_elevated_setup;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use setup::run_setup_refresh;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use setup::run_setup_refresh_with_extra_read_roots;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use setup::sandbox_dir;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use setup::sandbox_secrets_dir;
|
||||
|
||||
@@ -61,6 +61,47 @@ pub fn run_setup_refresh(
|
||||
command_cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
codex_home: &Path,
|
||||
) -> Result<()> {
|
||||
run_setup_refresh_inner(
|
||||
policy,
|
||||
policy_cwd,
|
||||
command_cwd,
|
||||
env_map,
|
||||
codex_home,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_setup_refresh_with_extra_read_roots(
|
||||
policy: &SandboxPolicy,
|
||||
policy_cwd: &Path,
|
||||
command_cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
codex_home: &Path,
|
||||
extra_read_roots: Vec<PathBuf>,
|
||||
) -> Result<()> {
|
||||
let mut read_roots = gather_read_roots(command_cwd, policy);
|
||||
read_roots.extend(extra_read_roots);
|
||||
run_setup_refresh_inner(
|
||||
policy,
|
||||
policy_cwd,
|
||||
command_cwd,
|
||||
env_map,
|
||||
codex_home,
|
||||
Some(read_roots),
|
||||
Some(Vec::new()),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_setup_refresh_inner(
|
||||
policy: &SandboxPolicy,
|
||||
policy_cwd: &Path,
|
||||
command_cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
codex_home: &Path,
|
||||
read_roots_override: Option<Vec<PathBuf>>,
|
||||
write_roots_override: Option<Vec<PathBuf>>,
|
||||
) -> Result<()> {
|
||||
// Skip in danger-full-access.
|
||||
if matches!(
|
||||
@@ -75,8 +116,8 @@ pub fn run_setup_refresh(
|
||||
command_cwd,
|
||||
env_map,
|
||||
codex_home,
|
||||
None,
|
||||
None,
|
||||
read_roots_override,
|
||||
write_roots_override,
|
||||
);
|
||||
let payload = ElevationPayload {
|
||||
version: SETUP_VERSION,
|
||||
|
||||
Reference in New Issue
Block a user