mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Merge branch 'main' into rhan/oob-thread-metadata
This commit is contained in:
@@ -32,9 +32,8 @@ codex_rust_crate(
|
||||
"//codex-rs:node-version.txt",
|
||||
],
|
||||
rustc_env = {
|
||||
# Askama resolves template paths relative to CARGO_MANIFEST_DIR. In
|
||||
# Bazel, the Cargo-provided absolute source path points outside the
|
||||
# sandbox, so keep the manifest root anchored inside the execroot.
|
||||
# Keep manifest-root path lookups inside the Bazel execroot for code
|
||||
# that relies on env!("CARGO_MANIFEST_DIR").
|
||||
"CARGO_MANIFEST_DIR": "codex-rs/core",
|
||||
},
|
||||
integration_compile_data_extra = [
|
||||
|
||||
@@ -18,10 +18,9 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
arc-swap = "1.8.2"
|
||||
arc-swap = { workspace = true }
|
||||
async-channel = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
askama = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bm25 = { workspace = true }
|
||||
chardetng = { workspace = true }
|
||||
@@ -46,7 +45,6 @@ codex-hooks = { workspace = true }
|
||||
codex-instructions = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-artifacts = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-rollout = { workspace = true }
|
||||
@@ -54,6 +52,7 @@ codex-rmcp-client = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-cache = { workspace = true }
|
||||
codex-utils-image = { workspace = true }
|
||||
@@ -66,6 +65,7 @@ codex-utils-readiness = { workspace = true }
|
||||
codex-secrets = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
codex-utils-stream-parser = { workspace = true }
|
||||
codex-utils-template = { workspace = true }
|
||||
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
|
||||
csv = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
@@ -149,7 +149,6 @@ codex-arg0 = { workspace = true }
|
||||
codex-otel = { workspace = true, features = [
|
||||
"disable-default-metrics-exporter",
|
||||
] }
|
||||
codex-test-macros = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
|
||||
+9
-32
@@ -17,32 +17,8 @@ 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_launch_services = true`:
|
||||
enables LaunchServices lookups and open/launch operations.
|
||||
- `macos_accessibility = true`:
|
||||
enables `com.apple.axserver` mach lookup.
|
||||
- `macos_calendar = true`:
|
||||
enables `com.apple.CalendarAgent` mach lookup.
|
||||
- `macos_contacts = "read_only"`:
|
||||
enables Address Book read access and Contacts read services.
|
||||
- `macos_contacts = "read_write"`:
|
||||
includes the readonly Contacts clauses plus Address Book writes and keychain/temp helpers required for writes.
|
||||
Seatbelt also keeps the legacy default preferences read access
|
||||
(`user-preference-read`) needed for cfprefs-backed macOS behavior.
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -59,12 +35,13 @@ only when the split filesystem policy round-trips through the legacy
|
||||
cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the
|
||||
more specific writable child must reopen under a denied parent.
|
||||
|
||||
The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and
|
||||
supports the required argv-rewrite flags, and falls back to the vendored
|
||||
bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is
|
||||
missing or too old to support the required flags, Codex also surfaces a startup
|
||||
warning through its normal notification path instead of printing directly from
|
||||
the sandbox helper.
|
||||
The Linux sandbox helper prefers the first `bwrap` found on `PATH` outside the
|
||||
current working directory whenever it is available. If `bwrap` is present but
|
||||
too old to support `--argv0`, the helper keeps using system bubblewrap and
|
||||
switches to a no-`--argv0` compatibility path for the inner re-exec. If
|
||||
`bwrap` is missing, it falls back to the vendored bubblewrap path compiled into
|
||||
the binary and Codex surfaces a startup warning through its normal notification
|
||||
path instead of printing directly from the sandbox helper.
|
||||
|
||||
### Windows
|
||||
|
||||
|
||||
@@ -338,9 +338,6 @@
|
||||
"apps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -745,6 +742,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpServerToolConfig": {
|
||||
"additionalProperties": false,
|
||||
"description": "Per-tool approval settings for a single MCP server tool.",
|
||||
"properties": {
|
||||
"approval_mode": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AppToolApproval"
|
||||
}
|
||||
],
|
||||
"description": "Approval mode for this tool."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MemoriesToml": {
|
||||
"additionalProperties": false,
|
||||
"description": "Memories settings loaded from config.toml.",
|
||||
@@ -1313,6 +1325,11 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"default": null,
|
||||
"description": "Legacy display-name field accepted for backward compatibility.",
|
||||
"type": "string"
|
||||
},
|
||||
"oauth_resource": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
@@ -1344,6 +1361,13 @@
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"tools": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/McpServerToolConfig"
|
||||
},
|
||||
"default": null,
|
||||
"type": "object"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1914,10 +1938,6 @@
|
||||
"experimental_compact_prompt_file": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"experimental_exec_server_url": {
|
||||
"description": "Experimental / do not use. Overrides the URL used when connecting to a remote exec server.",
|
||||
"type": "string"
|
||||
},
|
||||
"experimental_realtime_start_instructions": {
|
||||
"description": "Experimental / do not use. Replaces the built-in realtime start instructions inserted into developer messages when realtime becomes active.",
|
||||
"type": "string"
|
||||
@@ -1955,9 +1975,6 @@
|
||||
"apps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ use tracing::warn;
|
||||
|
||||
const AGENT_NAMES: &str = include_str!("agent_names.txt");
|
||||
const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.";
|
||||
const ROOT_LAST_TASK_MESSAGE: &str = "Main thread";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SpawnAgentOptions {
|
||||
@@ -650,12 +651,6 @@ impl AgentControl {
|
||||
let agent_path = current_agent_path
|
||||
.resolve(agent_reference)
|
||||
.map_err(CodexErr::UnsupportedOperation)?;
|
||||
if agent_path.is_root() {
|
||||
return Err(CodexErr::UnsupportedOperation(
|
||||
"root is not a spawned agent".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(thread_id) = self.state.agent_id_for_path(&agent_path) {
|
||||
return Ok(thread_id);
|
||||
}
|
||||
@@ -737,7 +732,21 @@ impl AgentControl {
|
||||
})
|
||||
});
|
||||
|
||||
let mut agents = Vec::with_capacity(live_agents.len());
|
||||
let root_path = AgentPath::root();
|
||||
let mut agents = Vec::with_capacity(live_agents.len().saturating_add(1));
|
||||
if resolved_prefix
|
||||
.as_ref()
|
||||
.is_none_or(|prefix| agent_matches_prefix(Some(&root_path), prefix))
|
||||
&& let Some(root_thread_id) = self.state.agent_id_for_path(&root_path)
|
||||
&& let Ok(root_thread) = state.get_thread(root_thread_id).await
|
||||
{
|
||||
agents.push(ListedAgent {
|
||||
agent_name: root_path.to_string(),
|
||||
agent_status: root_thread.agent_status().await,
|
||||
last_task_message: Some(ROOT_LAST_TASK_MESSAGE.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
for metadata in live_agents {
|
||||
let Some(thread_id) = metadata.agent_id else {
|
||||
continue;
|
||||
|
||||
@@ -75,6 +75,9 @@ impl AgentControlHarness {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
Self {
|
||||
@@ -811,6 +814,9 @@ async fn spawn_agent_respects_max_threads_limit() {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
|
||||
@@ -854,6 +860,9 @@ async fn spawn_agent_releases_slot_after_shutdown() {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
|
||||
@@ -888,6 +897,9 @@ async fn spawn_agent_limit_shared_across_clones() {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
let cloned = control.clone();
|
||||
@@ -924,6 +936,9 @@ async fn resume_agent_respects_max_threads_limit() {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
|
||||
@@ -971,6 +986,9 @@ async fn resume_agent_releases_slot_after_resume_failure() {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
|
||||
@@ -1361,6 +1379,9 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let control = manager.agent_control();
|
||||
let harness = AgentControlHarness {
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_api::TransportError;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_api::rate_limits::parse_promo_message;
|
||||
use codex_api::rate_limits::parse_rate_limit_for_limit;
|
||||
use codex_login::token_data::PlanType;
|
||||
use http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
@@ -16,7 +17,6 @@ use crate::error::RetryLimitReachedError;
|
||||
use crate::error::UnexpectedResponseError;
|
||||
use crate::error::UsageLimitReachedError;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::token_data::PlanType;
|
||||
|
||||
pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
match err {
|
||||
|
||||
@@ -53,6 +53,7 @@ use chrono::Utc;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::Feature;
|
||||
use codex_features::unstable_features_warning_event;
|
||||
@@ -70,7 +71,6 @@ use codex_otel::current_span_w3c_trace_context;
|
||||
use codex_otel::set_parent_from_w3c_trace_context;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::approvals::ExecApprovalRequestSkillMetadata;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
@@ -402,6 +402,7 @@ pub(crate) struct CodexSpawnArgs {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) models_manager: Arc<ModelsManager>,
|
||||
pub(crate) environment_manager: Arc<EnvironmentManager>,
|
||||
pub(crate) skills_manager: Arc<SkillsManager>,
|
||||
pub(crate) plugins_manager: Arc<PluginsManager>,
|
||||
pub(crate) mcp_manager: Arc<McpManager>,
|
||||
@@ -455,6 +456,7 @@ impl Codex {
|
||||
mut config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
environment_manager,
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
@@ -646,6 +648,7 @@ impl Codex {
|
||||
agent_status_tx.clone(),
|
||||
conversation_history,
|
||||
session_source_clone,
|
||||
environment_manager,
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager.clone(),
|
||||
@@ -863,6 +866,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) features: ManagedFeatures,
|
||||
pub(crate) ghost_snapshot: GhostSnapshotConfig,
|
||||
pub(crate) final_output_json_schema: Option<Value>,
|
||||
pub(crate) codex_self_exe: Option<PathBuf>,
|
||||
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
|
||||
pub(crate) truncation_policy: TruncationPolicy,
|
||||
@@ -970,6 +974,7 @@ impl TurnContext {
|
||||
features,
|
||||
ghost_snapshot: self.ghost_snapshot.clone(),
|
||||
final_output_json_schema: self.final_output_json_schema.clone(),
|
||||
codex_self_exe: self.codex_self_exe.clone(),
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
truncation_policy,
|
||||
@@ -1427,6 +1432,7 @@ impl Session {
|
||||
features: per_turn_config.features.clone(),
|
||||
ghost_snapshot: per_turn_config.ghost_snapshot.clone(),
|
||||
final_output_json_schema: None,
|
||||
codex_self_exe: per_turn_config.codex_self_exe.clone(),
|
||||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
@@ -1450,6 +1456,7 @@ impl Session {
|
||||
agent_status: watch::Sender<AgentStatus>,
|
||||
initial_history: InitialHistory,
|
||||
session_source: SessionSource,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
@@ -1862,7 +1869,6 @@ impl Session {
|
||||
session_telemetry,
|
||||
models_manager: Arc::clone(&models_manager),
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
execve_session_approvals: RwLock::new(HashMap::new()),
|
||||
skills_manager,
|
||||
plugins_manager: Arc::clone(&plugins_manager),
|
||||
mcp_manager: Arc::clone(&mcp_manager),
|
||||
@@ -1884,9 +1890,7 @@ impl Session {
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(
|
||||
config.js_repl_node_path.clone(),
|
||||
),
|
||||
environment: Arc::new(
|
||||
Environment::create(config.experimental_exec_server_url.clone()).await?,
|
||||
),
|
||||
environment: environment_manager.current().await?,
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
@@ -2892,7 +2896,6 @@ impl Session {
|
||||
network_approval_context: Option<NetworkApprovalContext>,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
skill_metadata: Option<ExecApprovalRequestSkillMetadata>,
|
||||
available_decisions: Option<Vec<ReviewDecision>>,
|
||||
) -> ReviewDecision {
|
||||
// command-level approvals use `call_id`.
|
||||
@@ -2946,7 +2949,6 @@ impl Session {
|
||||
proposed_execpolicy_amendment,
|
||||
proposed_network_policy_amendments,
|
||||
additional_permissions,
|
||||
skill_metadata,
|
||||
available_decisions: Some(available_decisions),
|
||||
parsed_cmd,
|
||||
});
|
||||
@@ -5474,6 +5476,7 @@ async fn spawn_review_thread(
|
||||
shell_environment_policy: parent_turn_context.shell_environment_policy.clone(),
|
||||
cwd: parent_turn_context.cwd.clone(),
|
||||
final_output_json_schema: None,
|
||||
codex_self_exe: parent_turn_context.codex_self_exe.clone(),
|
||||
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
js_repl: Arc::clone(&sess.js_repl),
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -76,6 +77,9 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
environment_manager: Arc::new(EnvironmentManager::new(
|
||||
parent_ctx.environment.exec_server_url().map(str::to_owned),
|
||||
)),
|
||||
skills_manager: Arc::clone(&parent_session.services.skills_manager),
|
||||
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
|
||||
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
|
||||
@@ -432,7 +436,6 @@ async fn handle_exec_approval(
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
additional_permissions,
|
||||
skill_metadata,
|
||||
available_decisions,
|
||||
..
|
||||
} = event;
|
||||
@@ -476,7 +479,6 @@ async fn handle_exec_approval(
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
additional_permissions,
|
||||
skill_metadata,
|
||||
available_decisions,
|
||||
),
|
||||
parent_session,
|
||||
|
||||
@@ -286,7 +286,6 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
skill_metadata: None,
|
||||
available_decisions: Some(vec![
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::Abort,
|
||||
|
||||
@@ -1162,35 +1162,10 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
// The parent rollout writer drains asynchronously after turn completion.
|
||||
// Wait until the persisted JSONL includes the source user turn before forking from it.
|
||||
let mut source_history_persisted = false;
|
||||
for _ in 0..100 {
|
||||
let history = RolloutRecorder::get_rollout_history(&rollout_path).await;
|
||||
source_history_persisted = history.ok().is_some_and(|history| {
|
||||
history.get_rollout_items().into_iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. })
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| {
|
||||
matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == "fork seed"
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
});
|
||||
if source_history_persisted {
|
||||
break;
|
||||
}
|
||||
sleep(StdDuration::from_millis(10)).await;
|
||||
}
|
||||
assert!(
|
||||
source_history_persisted,
|
||||
"source rollout should contain the completed pre-fork user turn before forking"
|
||||
);
|
||||
// Forking reads the persisted rollout JSONL, so force the completed source turn to disk
|
||||
// before snapshotting from it.
|
||||
initial.codex.ensure_rollout_materialized().await;
|
||||
initial.codex.flush_rollout().await;
|
||||
|
||||
let mut fork_config = initial.config.clone();
|
||||
fork_config.permissions.approval_policy =
|
||||
@@ -2551,6 +2526,9 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
agent_status_tx,
|
||||
InitialHistory::New,
|
||||
SessionSource::Exec,
|
||||
Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
@@ -2644,7 +2622,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
let environment = Arc::new(
|
||||
codex_exec_server::Environment::create(None)
|
||||
codex_exec_server::Environment::create(/*exec_server_url*/ None)
|
||||
.await
|
||||
.expect("create environment"),
|
||||
);
|
||||
@@ -2680,7 +2658,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
session_telemetry: session_telemetry.clone(),
|
||||
models_manager: Arc::clone(&models_manager),
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
execve_session_approvals: RwLock::new(HashMap::new()),
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
@@ -3482,7 +3459,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
let environment = Arc::new(
|
||||
codex_exec_server::Environment::create(None)
|
||||
codex_exec_server::Environment::create(/*exec_server_url*/ None)
|
||||
.await
|
||||
.expect("create environment"),
|
||||
);
|
||||
@@ -3518,7 +3495,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
session_telemetry: session_telemetry.clone(),
|
||||
models_manager: Arc::clone(&models_manager),
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
execve_session_approvals: RwLock::new(HashMap::new()),
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
@@ -159,7 +160,6 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
|
||||
enabled: Some(true),
|
||||
}),
|
||||
file_system: None,
|
||||
macos: None,
|
||||
},
|
||||
"justification": params.justification.clone(),
|
||||
})
|
||||
@@ -233,7 +233,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
|
||||
|
||||
assert_eq!(
|
||||
output,
|
||||
"missing `additional_permissions`; provide at least one of `network`, `file_system`, or `macos` when using `with_additional_permissions`"
|
||||
"missing `additional_permissions`; provide at least one of `network` or `file_system` when using `with_additional_permissions`"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,6 +437,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
|
||||
@@ -74,6 +74,16 @@ impl CodexThread {
|
||||
self.codex.shutdown_and_wait().await
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub async fn ensure_rollout_materialized(&self) {
|
||||
self.codex.session.ensure_rollout_materialized().await;
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub async fn flush_rollout(&self) {
|
||||
self.codex.session.flush_rollout().await;
|
||||
}
|
||||
|
||||
pub async fn submit_with_trace(
|
||||
&self,
|
||||
op: Op,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::edit::apply_blocking;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::ApprovalsReviewer;
|
||||
use crate::config::types::BundledSkillsConfig;
|
||||
use crate::config::types::FeedbackConfigToml;
|
||||
use crate::config::types::HistoryPersistence;
|
||||
use crate::config::types::McpServerToolConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::config::types::MemoriesConfig;
|
||||
use crate::config::types::MemoriesToml;
|
||||
@@ -57,6 +59,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +80,7 @@ fn http_mcp(url: &str) -> McpServerConfig {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1853,6 +1857,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1958,6 +1963,76 @@ startup_timeout_ms = 2500
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_toml_parses_per_tool_approval_overrides() {
|
||||
let config = toml::from_str::<ConfigToml>(
|
||||
r#"
|
||||
[mcp_servers.docs]
|
||||
command = "docs-server"
|
||||
name = "Docs"
|
||||
|
||||
[mcp_servers.docs.tools.search]
|
||||
approval_mode = "approve"
|
||||
"#,
|
||||
)
|
||||
.expect("TOML deserialization should succeed");
|
||||
let tool = config
|
||||
.mcp_servers
|
||||
.get("docs")
|
||||
.and_then(|server| server.tools.get("search"))
|
||||
.expect("docs/search tool config exists");
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
&McpServerToolConfig {
|
||||
approval_mode: Some(AppToolApproval::Approve),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_toml_ignores_unknown_server_fields() {
|
||||
let config = toml::from_str::<ConfigToml>(
|
||||
r#"
|
||||
[mcp_servers.docs]
|
||||
command = "docs-server"
|
||||
trust_level = "trusted"
|
||||
"#,
|
||||
)
|
||||
.expect("unknown MCP server fields should be ignored");
|
||||
|
||||
assert_eq!(
|
||||
config.mcp_servers.get("docs"),
|
||||
Some(&stdio_mcp("docs-server"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_toml_parses_tool_approval_override_for_reserved_name() {
|
||||
let config = toml::from_str::<ConfigToml>(
|
||||
r#"
|
||||
[mcp_servers.docs]
|
||||
command = "docs-server"
|
||||
|
||||
[mcp_servers.docs.tools.command]
|
||||
approval_mode = "approve"
|
||||
"#,
|
||||
)
|
||||
.expect("TOML deserialization should succeed");
|
||||
let tool = config
|
||||
.mcp_servers
|
||||
.get("docs")
|
||||
.and_then(|server| server.tools.get("command"))
|
||||
.expect("docs/command tool config exists");
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
&McpServerToolConfig {
|
||||
approval_mode: Some(AppToolApproval::Approve),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -2009,6 +2084,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -2081,6 +2157,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -2133,6 +2210,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -2183,6 +2261,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -2249,6 +2328,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
apply_blocking(
|
||||
@@ -2327,6 +2407,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -2358,6 +2439,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
apply_blocking(
|
||||
@@ -2424,6 +2506,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -2445,6 +2528,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
),
|
||||
]);
|
||||
@@ -2529,6 +2613,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -2575,6 +2660,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -2621,6 +2707,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
|
||||
disabled_tools: Some(vec!["blocked".to_string()]),
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -2671,6 +2758,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: Some("https://resource.example.com".to_string()),
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -4291,7 +4379,6 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: true,
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
@@ -4320,6 +4407,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
history: History::default(),
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_self_exe: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
js_repl_node_path: None,
|
||||
@@ -4335,7 +4423,6 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_exec_server_url: None,
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_start_instructions: None,
|
||||
experimental_realtime_ws_base_url: None,
|
||||
@@ -4434,7 +4521,6 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: true,
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
@@ -4463,6 +4549,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
history: History::default(),
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_self_exe: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
js_repl_node_path: None,
|
||||
@@ -4478,7 +4565,6 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_exec_server_url: None,
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_start_instructions: None,
|
||||
experimental_realtime_ws_base_url: None,
|
||||
@@ -4575,7 +4661,6 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: true,
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
@@ -4604,6 +4689,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
history: History::default(),
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_self_exe: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
js_repl_node_path: None,
|
||||
@@ -4619,7 +4705,6 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_exec_server_url: None,
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_start_instructions: None,
|
||||
experimental_realtime_ws_base_url: None,
|
||||
@@ -4702,7 +4787,6 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: true,
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
@@ -4731,6 +4815,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
history: History::default(),
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_self_exe: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
js_repl_node_path: None,
|
||||
@@ -4746,7 +4831,6 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
model_verbosity: Some(Verbosity::High),
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
experimental_exec_server_url: None,
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_start_instructions: None,
|
||||
experimental_realtime_ws_base_url: None,
|
||||
@@ -5604,78 +5688,6 @@ shell_tool = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn system_bwrap_warning_reports_missing_system_bwrap() {
|
||||
let warning = system_bwrap_warning_for_path(Path::new("/definitely/not/a/bwrap"))
|
||||
.expect("missing system bwrap should emit a warning");
|
||||
|
||||
assert!(warning.contains("could not find system bubblewrap"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn system_bwrap_warning_reports_too_old_system_bwrap() {
|
||||
let fake_bwrap = write_fake_bwrap(
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "--help" ]; then
|
||||
echo 'usage: bwrap [OPTION...] COMMAND'
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
"#,
|
||||
);
|
||||
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
|
||||
let warning = system_bwrap_warning_for_path(fake_bwrap_path)
|
||||
.expect("old system bwrap should emit a warning");
|
||||
|
||||
assert!(warning.contains("too old to support `--argv0`"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn system_bwrap_warning_skips_supported_system_bwrap() {
|
||||
let fake_bwrap = write_fake_bwrap(
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "--help" ]; then
|
||||
echo ' --argv0 PROGRAM'
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
"#,
|
||||
);
|
||||
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
|
||||
|
||||
assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn system_bwrap_warning_is_disabled_off_linux() {
|
||||
assert!(system_bwrap_warning().is_none());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn write_fake_bwrap(contents: &str) -> tempfile::TempPath {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
// Bazel can mount the OS temp directory `noexec`, so prefer the current
|
||||
// working directory for fake executables and fall back to the default temp
|
||||
// dir outside that environment.
|
||||
let temp_file = std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|dir| NamedTempFile::new_in(dir).ok())
|
||||
.unwrap_or_else(|| NamedTempFile::new().expect("temp file"));
|
||||
// Linux rejects exec-ing a file that is still open for writing.
|
||||
let path = temp_file.into_temp_path();
|
||||
fs::write(&path, contents).expect("write fake bwrap");
|
||||
let permissions = fs::Permissions::from_mode(0o755);
|
||||
fs::set_permissions(&path, permissions).expect("chmod fake bwrap");
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -> std::io::Result<()>
|
||||
{
|
||||
@@ -5964,34 +5976,6 @@ experimental_realtime_start_instructions = "start instructions from config"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experimental_exec_server_url_loads_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
r#"
|
||||
experimental_exec_server_url = "http://127.0.0.1:8080"
|
||||
"#,
|
||||
)
|
||||
.expect("TOML deserialization should succeed");
|
||||
|
||||
assert_eq!(
|
||||
cfg.experimental_exec_server_url.as_deref(),
|
||||
Some("http://127.0.0.1:8080")
|
||||
);
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
config.experimental_exec_server_url.as_deref(),
|
||||
Some("http://127.0.0.1:8080")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
|
||||
@@ -125,7 +125,9 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) ->
|
||||
|
||||
// TODO(jif) move to a dedicated file
|
||||
mod document_helpers {
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerToolConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use toml_edit::Array as TomlArray;
|
||||
use toml_edit::InlineTable;
|
||||
@@ -248,10 +250,32 @@ mod document_helpers {
|
||||
{
|
||||
entry["oauth_resource"] = value(resource.clone());
|
||||
}
|
||||
if !config.tools.is_empty() {
|
||||
let mut tools = new_implicit_table();
|
||||
let mut tool_entries: Vec<_> = config.tools.iter().collect();
|
||||
tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
for (name, tool_config) in tool_entries {
|
||||
tools.insert(name, serialize_mcp_server_tool(tool_config));
|
||||
}
|
||||
entry.insert("tools", TomlItem::Table(tools));
|
||||
}
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
if let Some(approval_mode) = config.approval_mode {
|
||||
entry["approval_mode"] = value(match approval_mode {
|
||||
AppToolApproval::Auto => "auto",
|
||||
AppToolApproval::Prompt => "prompt",
|
||||
AppToolApproval::Approve => "approve",
|
||||
});
|
||||
}
|
||||
TomlItem::Table(entry)
|
||||
}
|
||||
|
||||
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
|
||||
TomlItem::Table(serialize_mcp_server_table(config))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::McpServerToolConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -582,6 +584,7 @@ fn blocking_replace_mcp_servers_round_trips() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -607,6 +610,7 @@ fn blocking_replace_mcp_servers_round_trips() {
|
||||
disabled_tools: Some(vec!["forbidden".to_string()]),
|
||||
scopes: None,
|
||||
oauth_resource: Some("https://resource.example.com".to_string()),
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -643,6 +647,53 @@ B = \"2\"
|
||||
assert_eq!(raw, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
let mut servers = BTreeMap::new();
|
||||
servers.insert(
|
||||
"docs".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "docs-server".to_string(),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::from([(
|
||||
"search".to_string(),
|
||||
McpServerToolConfig {
|
||||
approval_mode: Some(AppToolApproval::Approve),
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist");
|
||||
|
||||
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = "\
|
||||
[mcp_servers.docs]
|
||||
command = \"docs-server\"
|
||||
|
||||
[mcp_servers.docs.tools.search]
|
||||
approval_mode = \"approve\"
|
||||
";
|
||||
assert_eq!(raw, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_replace_mcp_servers_preserves_inline_comments() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
@@ -676,6 +727,7 @@ foo = { command = "cmd" }
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -721,6 +773,7 @@ foo = { command = "cmd" } # keep me
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -765,6 +818,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -810,6 +864,7 @@ foo = { command = "cmd" }
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ use codex_protocol::config_types::WebSearchConfig;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::config_types::WebSearchToolConfig;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
@@ -96,8 +95,6 @@ use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::permissions::compile_permission_profile;
|
||||
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
|
||||
@@ -120,7 +117,7 @@ pub use codex_config::Constrained;
|
||||
pub use codex_config::ConstraintError;
|
||||
pub use codex_config::ConstraintResult;
|
||||
pub use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
|
||||
pub use codex_sandboxing::system_bwrap_warning;
|
||||
pub use managed_features::ManagedFeatures;
|
||||
pub use network_proxy_spec::NetworkProxySpec;
|
||||
pub use network_proxy_spec::StartedNetworkProxy;
|
||||
@@ -146,55 +143,12 @@ pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL";
|
||||
#[cfg(target_os = "linux")]
|
||||
const SYSTEM_BWRAP_PATH: &str = "/usr/bin/bwrap";
|
||||
const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [
|
||||
OPENAI_PROVIDER_ID,
|
||||
OLLAMA_OSS_PROVIDER_ID,
|
||||
LMSTUDIO_OSS_PROVIDER_ID,
|
||||
];
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn system_bwrap_warning() -> Option<String> {
|
||||
system_bwrap_warning_for_path(Path::new(SYSTEM_BWRAP_PATH))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn system_bwrap_warning() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option<String> {
|
||||
if !system_bwrap_path.is_file() {
|
||||
return Some(format!(
|
||||
"Codex could not find system bubblewrap at {}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.",
|
||||
system_bwrap_path.display()
|
||||
));
|
||||
}
|
||||
if system_bwrap_supports_argv0(system_bwrap_path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.",
|
||||
system_bwrap_path.display()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool {
|
||||
// bubblewrap added `--argv0` in v0.9.0:
|
||||
// https://github.com/containers/bubblewrap/releases/tag/v0.9.0
|
||||
let output = match Command::new(system_bwrap_path).arg("--help").output() {
|
||||
Ok(output) => output,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
stdout.contains("--argv0") || stderr.contains("--argv0")
|
||||
}
|
||||
|
||||
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
|
||||
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
|
||||
let trimmed = raw.trim();
|
||||
@@ -208,6 +162,7 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
|
||||
Some(resolved_cwd.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_config() -> Config {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
@@ -250,9 +205,6 @@ pub struct Permissions {
|
||||
pub windows_sandbox_mode: Option<WindowsSandboxModeToml>,
|
||||
/// Whether the final Windows sandboxed child should run on a private desktop.
|
||||
pub windows_sandbox_private_desktop: bool,
|
||||
/// Optional macOS seatbelt extension profile used to extend default
|
||||
/// seatbelt permissions when running under seatbelt.
|
||||
pub macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
}
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
@@ -476,6 +428,10 @@ pub struct Config {
|
||||
/// output will be hyperlinked using the specified URI scheme.
|
||||
pub file_opener: UriBasedFileOpener,
|
||||
|
||||
/// Path to the current Codex executable. This cannot be set in the config
|
||||
/// file: it must be set in code via [`ConfigOverrides`].
|
||||
pub codex_self_exe: Option<PathBuf>,
|
||||
|
||||
/// Path to the `codex-linux-sandbox` executable. This must be set if
|
||||
/// [`codex_sandboxing::SandboxType::LinuxSeccomp`] is used. Note that this
|
||||
/// cannot be set in the config file: it must be set in code via
|
||||
@@ -526,10 +482,6 @@ pub struct Config {
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: String,
|
||||
|
||||
/// Experimental / do not use. Overrides the URL used when connecting to
|
||||
/// a remote exec server.
|
||||
pub experimental_exec_server_url: Option<String>,
|
||||
|
||||
/// Machine-local realtime audio device preferences used by realtime voice.
|
||||
pub realtime_audio: RealtimeAudioConfig,
|
||||
|
||||
@@ -764,7 +716,7 @@ impl Config {
|
||||
/// designed to use [AskForApproval::Never] exclusively.
|
||||
///
|
||||
/// Further, [ConfigOverrides] contains some options that are not supported
|
||||
/// in [ConfigToml], such as `cwd`, `codex_linux_sandbox_exe`, and
|
||||
/// in [ConfigToml], such as `cwd`, `codex_self_exe`, `codex_linux_sandbox_exe`, and
|
||||
/// `main_execve_wrapper_exe`.
|
||||
pub async fn load_with_cli_overrides_and_harness_overrides(
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
@@ -1319,10 +1271,6 @@ pub struct ConfigToml {
|
||||
/// Base URL override for the built-in `openai` model provider.
|
||||
pub openai_base_url: Option<String>,
|
||||
|
||||
/// Experimental / do not use. Overrides the URL used when connecting to
|
||||
/// a remote exec server.
|
||||
pub experimental_exec_server_url: Option<String>,
|
||||
|
||||
/// Machine-local realtime audio device preferences used by realtime voice.
|
||||
#[serde(default)]
|
||||
pub audio: Option<RealtimeAudioToml>,
|
||||
@@ -1846,6 +1794,7 @@ pub struct ConfigOverrides {
|
||||
pub model_provider: Option<String>,
|
||||
pub service_tier: Option<Option<ServiceTier>>,
|
||||
pub config_profile: Option<String>,
|
||||
pub codex_self_exe: Option<PathBuf>,
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub main_execve_wrapper_exe: Option<PathBuf>,
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
@@ -2044,6 +1993,7 @@ impl Config {
|
||||
model_provider,
|
||||
service_tier: service_tier_override,
|
||||
config_profile: config_profile_key,
|
||||
codex_self_exe,
|
||||
codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe,
|
||||
js_repl_node_path: js_repl_node_path_override,
|
||||
@@ -2602,7 +2552,6 @@ impl Config {
|
||||
shell_environment_policy,
|
||||
windows_sandbox_mode,
|
||||
windows_sandbox_private_desktop,
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
},
|
||||
approvals_reviewer,
|
||||
enforce_residency: enforce_residency.value,
|
||||
@@ -2650,6 +2599,7 @@ impl Config {
|
||||
history,
|
||||
ephemeral: ephemeral.unwrap_or_default(),
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
codex_self_exe,
|
||||
codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe,
|
||||
js_repl_node_path,
|
||||
@@ -2678,7 +2628,6 @@ impl Config {
|
||||
.chatgpt_base_url
|
||||
.or(cfg.chatgpt_base_url)
|
||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||
experimental_exec_server_url: cfg.experimental_exec_server_url,
|
||||
realtime_audio: cfg
|
||||
.audio
|
||||
.map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig {
|
||||
|
||||
@@ -22,6 +22,9 @@ pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
|
||||
let mut validation = ObjectValidation::default();
|
||||
for feature in FEATURES {
|
||||
if feature.id == codex_features::Feature::Artifact {
|
||||
continue;
|
||||
}
|
||||
validation
|
||||
.properties
|
||||
.insert(feature.key.to_string(), schema_gen.subschema_for::<bool>());
|
||||
|
||||
@@ -108,6 +108,10 @@ pub struct McpServerConfig {
|
||||
/// Optional OAuth resource parameter to include during MCP login (RFC 8707).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_resource: Option<String>,
|
||||
|
||||
/// Per-tool approval settings keyed by tool name.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub tools: HashMap<String, McpServerToolConfig>,
|
||||
}
|
||||
|
||||
// Raw MCP config shape used for deserialization and JSON Schema generation.
|
||||
@@ -154,6 +158,11 @@ pub(crate) struct RawMcpServerConfig {
|
||||
pub scopes: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub oauth_resource: Option<String>,
|
||||
/// Legacy display-name field accepted for backward compatibility.
|
||||
#[serde(default, rename = "name")]
|
||||
pub _name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tools: Option<HashMap<String, McpServerToolConfig>>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
@@ -178,6 +187,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
let disabled_tools = raw.disabled_tools.clone();
|
||||
let scopes = raw.scopes.clone();
|
||||
let oauth_resource = raw.oauth_resource.clone();
|
||||
let tools = raw.tools.clone().unwrap_or_default();
|
||||
|
||||
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
|
||||
where
|
||||
@@ -236,6 +246,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
disabled_tools,
|
||||
scopes,
|
||||
oauth_resource,
|
||||
tools,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -496,6 +507,15 @@ pub enum AppToolApproval {
|
||||
Approve,
|
||||
}
|
||||
|
||||
/// Per-tool approval settings for a single MCP server tool.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct McpServerToolConfig {
|
||||
/// Approval mode for this tool.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approval_mode: Option<AppToolApproval>,
|
||||
}
|
||||
|
||||
/// Default settings that apply to all apps.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
|
||||
@@ -243,6 +243,40 @@ fn deserialize_server_config_with_tool_filters() {
|
||||
assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ignores_unknown_server_fields() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
trust_level = "trusted"
|
||||
"#,
|
||||
)
|
||||
.expect("should ignore unknown server fields");
|
||||
|
||||
assert_eq!(
|
||||
cfg,
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "echo".to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_skill_config_with_name_selector() {
|
||||
let cfg: SkillConfig = toml::from_str(
|
||||
|
||||
@@ -16,6 +16,7 @@ pub use codex_app_server_protocol::AppInfo;
|
||||
pub use codex_app_server_protocol::AppMetadata;
|
||||
use codex_connectors::AllConnectorsCacheKey;
|
||||
use codex_connectors::DirectoryListResponse;
|
||||
use codex_login::token_data::TokenData;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
@@ -43,7 +44,6 @@ use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::list_tool_suggest_discoverable_plugins;
|
||||
use crate::token_data::TokenData;
|
||||
use crate::tools::discoverable::DiscoverablePluginInfo;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use codex_features::Feature;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::network_policy_decision::NetworkPolicyDecisionPayload;
|
||||
use crate::token_data::KnownPlan;
|
||||
use crate::token_data::PlanType;
|
||||
use chrono::DateTime;
|
||||
use chrono::Datelike;
|
||||
use chrono::Local;
|
||||
@@ -9,6 +7,8 @@ use chrono::Utc;
|
||||
use codex_async_utils::CancelErr;
|
||||
pub use codex_login::auth::RefreshTokenFailedError;
|
||||
pub use codex_login::auth::RefreshTokenFailedReason;
|
||||
use codex_login::token_data::KnownPlan;
|
||||
use codex_login::token_data::PlanType;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
|
||||
+17
-12
@@ -272,7 +272,7 @@ pub fn build_exec_request(
|
||||
|
||||
let manager = SandboxManager::new();
|
||||
let command = SandboxCommand {
|
||||
program: program.clone(),
|
||||
program: program.clone().into(),
|
||||
args: args.to_vec(),
|
||||
cwd,
|
||||
env,
|
||||
@@ -292,8 +292,6 @@ pub fn build_exec_request(
|
||||
enforce_managed_network,
|
||||
network: network.as_ref(),
|
||||
sandbox_policy_cwd: sandbox_cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(),
|
||||
use_legacy_landlock,
|
||||
windows_sandbox_level,
|
||||
@@ -480,7 +478,11 @@ async fn exec_windows_sandbox(
|
||||
})?;
|
||||
let command_path = command.first().cloned();
|
||||
let sandbox_level = windows_sandbox_level;
|
||||
let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated);
|
||||
let proxy_enforced = network.is_some();
|
||||
// Windows firewall enforcement is tied to the logon-user sandbox identities, so
|
||||
// proxy-enforced sessions must use that backend even when the configured mode is
|
||||
// the default restricted-token sandbox.
|
||||
let use_elevated = proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated);
|
||||
let additional_deny_write_paths = windows_restricted_token_filesystem_overlay
|
||||
.map(|overlay| {
|
||||
overlay
|
||||
@@ -493,14 +495,17 @@ async fn exec_windows_sandbox(
|
||||
let spawn_res = tokio::task::spawn_blocking(move || {
|
||||
if use_elevated {
|
||||
run_windows_sandbox_capture_elevated(
|
||||
policy_str.as_str(),
|
||||
&sandbox_cwd,
|
||||
codex_home.as_ref(),
|
||||
command,
|
||||
&cwd,
|
||||
env,
|
||||
timeout_ms,
|
||||
windows_sandbox_private_desktop,
|
||||
codex_windows_sandbox::ElevatedSandboxCaptureRequest {
|
||||
policy_json_or_preset: policy_str.as_str(),
|
||||
sandbox_policy_cwd: &sandbox_cwd,
|
||||
codex_home: codex_home.as_ref(),
|
||||
command,
|
||||
cwd: &cwd,
|
||||
env_map: env,
|
||||
timeout_ms,
|
||||
use_private_desktop: windows_sandbox_private_desktop,
|
||||
proxy_enforced,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
run_windows_sandbox_capture_with_extra_deny_write_paths(
|
||||
|
||||
@@ -828,6 +828,33 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_approval_requirement_prompts_for_inline_additional_permissions_under_on_request() {
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec![
|
||||
"zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"touch requested-dir/requested-but-unused.txt".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"touch".to_string(),
|
||||
"requested-dir/requested-but-unused.txt".to_string(),
|
||||
])),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled()
|
||||
{
|
||||
|
||||
@@ -595,6 +595,19 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
|
||||
},
|
||||
]);
|
||||
|
||||
#[cfg(windows)]
|
||||
let expected_deny_write_paths = vec![
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(cwd.join(".codex"))
|
||||
.expect("absolute .codex"),
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
||||
.expect("absolute docs"),
|
||||
];
|
||||
#[cfg(not(windows))]
|
||||
let expected_deny_write_paths = vec![
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
||||
.expect("absolute docs"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_windows_restricted_token_filesystem_overlay(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
@@ -605,10 +618,7 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
),
|
||||
Ok(Some(WindowsRestrictedTokenFilesystemOverlay {
|
||||
additional_deny_write_paths: vec![
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
||||
.expect("absolute docs"),
|
||||
],
|
||||
additional_deny_write_paths: expected_deny_write_paths,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_hooks::PostToolUseOutcome;
|
||||
use codex_hooks::PostToolUseRequest;
|
||||
use codex_hooks::PreToolUseOutcome;
|
||||
use codex_hooks::PreToolUseRequest;
|
||||
use codex_hooks::SessionStartOutcome;
|
||||
@@ -15,6 +17,7 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
@@ -141,6 +144,33 @@ pub(crate) async fn run_pre_tool_use_hooks(
|
||||
if should_block { block_reason } else { None }
|
||||
}
|
||||
|
||||
pub(crate) async fn run_post_tool_use_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
tool_use_id: String,
|
||||
command: String,
|
||||
tool_response: Value,
|
||||
) -> PostToolUseOutcome {
|
||||
let request = PostToolUseRequest {
|
||||
session_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
tool_name: "Bash".to_string(),
|
||||
tool_use_id,
|
||||
command,
|
||||
tool_response,
|
||||
};
|
||||
let preview_runs = sess.hooks().preview_post_tool_use(&request);
|
||||
emit_hook_started_events(sess, turn_context, preview_runs).await;
|
||||
|
||||
let outcome = sess.hooks().run_post_tool_use(request).await;
|
||||
emit_hook_completed_events(sess, turn_context, outcome.hook_events.clone()).await;
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn run_user_prompt_submit_hooks(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::spawn::spawn_child_async;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
|
||||
use codex_sandboxing::landlock::allow_network_for_proxy;
|
||||
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies;
|
||||
use std::collections::HashMap;
|
||||
@@ -48,11 +49,24 @@ where
|
||||
use_legacy_landlock,
|
||||
allow_network_for_proxy(/*enforce_managed_network*/ false),
|
||||
);
|
||||
let arg0 = Some("codex-linux-sandbox");
|
||||
let codex_linux_sandbox_exe = codex_linux_sandbox_exe.as_ref();
|
||||
// Preserve the helper alias when we already have it; otherwise force argv0
|
||||
// so arg0 dispatch still reaches the Linux sandbox path.
|
||||
let arg0 = if codex_linux_sandbox_exe
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
== Some(CODEX_LINUX_SANDBOX_ARG0)
|
||||
{
|
||||
// Old bubblewrap builds without `--argv0` need a real helper path whose
|
||||
// basename still dispatches to the Linux sandbox entrypoint.
|
||||
codex_linux_sandbox_exe.to_string_lossy().into_owned()
|
||||
} else {
|
||||
CODEX_LINUX_SANDBOX_ARG0.to_string()
|
||||
};
|
||||
spawn_child_async(SpawnChildRequest {
|
||||
program: codex_linux_sandbox_exe.as_ref().to_path_buf(),
|
||||
program: codex_linux_sandbox_exe.to_path_buf(),
|
||||
args,
|
||||
arg0,
|
||||
arg0: Some(&arg0),
|
||||
cwd: command_cwd,
|
||||
network_sandbox_policy,
|
||||
network,
|
||||
|
||||
@@ -53,7 +53,6 @@ pub mod models_manager;
|
||||
mod network_policy_decision;
|
||||
pub mod network_proxy_loader;
|
||||
mod original_image_detail;
|
||||
mod packages;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
|
||||
pub use mcp_connection_manager::SandboxState;
|
||||
@@ -103,7 +102,6 @@ mod skills_watcher;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
mod text_encoding;
|
||||
pub use codex_login::token_data;
|
||||
mod unified_exec;
|
||||
pub mod windows_sandbox;
|
||||
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
|
||||
@@ -207,6 +205,7 @@ pub use client_common::REVIEW_PROMPT;
|
||||
pub use client_common::ResponseEvent;
|
||||
pub use client_common::ResponseStream;
|
||||
pub use codex_sandboxing::get_platform_sandbox;
|
||||
pub use codex_tools::parse_tool_input_schema;
|
||||
pub use compact::content_items_to_text;
|
||||
pub use event_mapping::parse_turn_item;
|
||||
pub use exec_policy::ExecPolicyError;
|
||||
@@ -214,7 +213,6 @@ pub use exec_policy::check_execpolicy_for_warnings;
|
||||
pub use exec_policy::format_exec_policy_error_with_source;
|
||||
pub use exec_policy::load_exec_policy;
|
||||
pub use file_watcher::FileWatcherEvent;
|
||||
pub use tools::spec::parse_tool_input_schema;
|
||||
pub use turn_metadata::build_turn_metadata_header;
|
||||
pub mod compact;
|
||||
pub mod memory_trace;
|
||||
|
||||
@@ -176,6 +176,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ async fn effective_mcp_servers_include_plugins_without_overriding_user_config()
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
config
|
||||
|
||||
@@ -428,6 +428,7 @@ fn mcp_dependency_to_server_config(
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -453,6 +454,7 @@ fn mcp_dependency_to_server_config(
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ fn skill_with_tools(tools: Vec<SkillToolDependency>) -> SkillMetadata {
|
||||
interface: None,
|
||||
dependencies: Some(SkillDependencies { tools }),
|
||||
policy: None,
|
||||
permission_profile: None,
|
||||
managed_network_override: None,
|
||||
path_to_skills_md: PathBuf::from("skill"),
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
@@ -48,6 +46,7 @@ fn collect_missing_respects_canonical_installed_key() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -97,6 +96,7 @@ fn collect_missing_dedupes_by_canonical_key_but_preserves_original_name() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
|
||||
@@ -542,6 +542,7 @@ fn mcp_init_error_display_prompts_for_github_pat() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
auth_status: McpAuthStatus::Unsupported,
|
||||
};
|
||||
@@ -590,6 +591,7 @@ fn mcp_init_error_display_reports_generic_errors() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
auth_status: McpAuthStatus::Unsupported,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::McpElicitationObjectType;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
@@ -12,8 +15,10 @@ use crate::arc_monitor::ArcMonitorOutcome;
|
||||
use crate::arc_monitor::monitor_action;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::load_global_mcp_servers;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::connectors;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
@@ -33,6 +38,7 @@ use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::sanitize_metric_tag_value;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -46,10 +52,18 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use toml_edit::value;
|
||||
use tracing::Instrument;
|
||||
use tracing::Span;
|
||||
use tracing::field::Empty;
|
||||
use url::Url;
|
||||
|
||||
const MCP_CALL_COUNT_METRIC: &str = "codex.mcp.call";
|
||||
const MCP_CALL_DURATION_METRIC: &str = "codex.mcp.call.duration_ms";
|
||||
|
||||
/// Handles the specified tool call dispatches the appropriate
|
||||
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
|
||||
@@ -100,6 +114,11 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
} else {
|
||||
connectors::AppToolPolicy::default()
|
||||
};
|
||||
let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
app_tool_policy.approval
|
||||
} else {
|
||||
custom_mcp_tool_approval_mode(turn_context.as_ref(), &server, &tool_name)
|
||||
};
|
||||
|
||||
if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled {
|
||||
let result = notify_mcp_tool_call_skip(
|
||||
@@ -113,7 +132,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
.await;
|
||||
let status = if result.is_ok() { "ok" } else { "error" };
|
||||
turn_context.session_telemetry.counter(
|
||||
"codex.mcp.call",
|
||||
MCP_CALL_COUNT_METRIC,
|
||||
/*inc*/ 1,
|
||||
&[("status", status)],
|
||||
);
|
||||
@@ -121,6 +140,19 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
}
|
||||
let request_meta =
|
||||
build_mcp_tool_call_request_meta(turn_context.as_ref(), &server, metadata.as_ref());
|
||||
let connector_id = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.connector_id.clone());
|
||||
let connector_name = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.connector_name.clone());
|
||||
let server_origin = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.server_origin(&server)
|
||||
.map(str::to_string);
|
||||
|
||||
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
@@ -134,26 +166,40 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
&call_id,
|
||||
&invocation,
|
||||
metadata.as_ref(),
|
||||
app_tool_policy.approval,
|
||||
approval_mode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let result = match decision {
|
||||
let (result, call_duration) = match decision {
|
||||
McpToolApprovalDecision::Accept
|
||||
| McpToolApprovalDecision::AcceptForSession
|
||||
| McpToolApprovalDecision::AcceptAndRemember => {
|
||||
maybe_mark_thread_memory_mode_polluted(sess.as_ref(), turn_context.as_ref()).await;
|
||||
|
||||
let start = Instant::now();
|
||||
let result = sess
|
||||
.call_tool(
|
||||
let result = async {
|
||||
sess.call_tool(
|
||||
&server,
|
||||
&tool_name,
|
||||
arguments_value.clone(),
|
||||
request_meta.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("tool call error: {e:?}"));
|
||||
.map_err(|e| format!("tool call error: {e:?}"))
|
||||
}
|
||||
.instrument(mcp_tool_call_span(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
McpToolCallSpanFields {
|
||||
server_name: &server,
|
||||
tool_name: &tool_name,
|
||||
call_id: &call_id,
|
||||
server_origin: server_origin.as_deref(),
|
||||
connector_id: connector_id.as_deref(),
|
||||
connector_name: connector_name.as_deref(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
let result = sanitize_mcp_tool_result_for_model(
|
||||
turn_context
|
||||
.model_info
|
||||
@@ -161,13 +207,14 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
.contains(&InputModality::Image),
|
||||
result,
|
||||
);
|
||||
if let Err(e) = &result {
|
||||
tracing::warn!("MCP tool call error: {e:?}");
|
||||
if let Err(error) = &result {
|
||||
tracing::warn!("MCP tool call error: {error:?}");
|
||||
}
|
||||
let duration = start.elapsed();
|
||||
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
invocation,
|
||||
duration: start.elapsed(),
|
||||
duration,
|
||||
result: result.clone(),
|
||||
});
|
||||
notify_mcp_tool_call_event(
|
||||
@@ -183,50 +230,62 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
&tool_name,
|
||||
)
|
||||
.await;
|
||||
result
|
||||
(result, Some(duration))
|
||||
}
|
||||
McpToolApprovalDecision::Decline => {
|
||||
let message = "user rejected MCP tool call".to_string();
|
||||
notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
(
|
||||
notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
.await,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
McpToolApprovalDecision::Cancel => {
|
||||
let message = "user cancelled MCP tool call".to_string();
|
||||
notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
(
|
||||
notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
.await,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
McpToolApprovalDecision::BlockedBySafetyMonitor(message) => {
|
||||
notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
(
|
||||
notify_mcp_tool_call_skip(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
.await,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
let status = if result.is_ok() { "ok" } else { "error" };
|
||||
turn_context.session_telemetry.counter(
|
||||
"codex.mcp.call",
|
||||
/*inc*/ 1,
|
||||
&[("status", status)],
|
||||
emit_mcp_call_metrics(
|
||||
turn_context.as_ref(),
|
||||
status,
|
||||
&tool_name,
|
||||
connector_id.as_deref(),
|
||||
connector_name.as_deref(),
|
||||
call_duration,
|
||||
);
|
||||
|
||||
return CallToolResult::from_result(result);
|
||||
@@ -236,10 +295,24 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
|
||||
let start = Instant::now();
|
||||
// Perform the tool call.
|
||||
let result = sess
|
||||
.call_tool(&server, &tool_name, arguments_value.clone(), request_meta)
|
||||
.await
|
||||
.map_err(|e| format!("tool call error: {e:?}"));
|
||||
let result = async {
|
||||
sess.call_tool(&server, &tool_name, arguments_value.clone(), request_meta)
|
||||
.await
|
||||
.map_err(|e| format!("tool call error: {e:?}"))
|
||||
}
|
||||
.instrument(mcp_tool_call_span(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
McpToolCallSpanFields {
|
||||
server_name: &server,
|
||||
tool_name: &tool_name,
|
||||
call_id: &call_id,
|
||||
server_origin: server_origin.as_deref(),
|
||||
connector_id: connector_id.as_deref(),
|
||||
connector_name: connector_name.as_deref(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
let result = sanitize_mcp_tool_result_for_model(
|
||||
turn_context
|
||||
.model_info
|
||||
@@ -247,13 +320,14 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
.contains(&InputModality::Image),
|
||||
result,
|
||||
);
|
||||
if let Err(e) = &result {
|
||||
tracing::warn!("MCP tool call error: {e:?}");
|
||||
if let Err(error) = &result {
|
||||
tracing::warn!("MCP tool call error: {error:?}");
|
||||
}
|
||||
let duration = start.elapsed();
|
||||
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
invocation,
|
||||
duration: start.elapsed(),
|
||||
duration,
|
||||
result: result.clone(),
|
||||
});
|
||||
|
||||
@@ -266,13 +340,119 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
maybe_track_codex_app_used(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
|
||||
|
||||
let status = if result.is_ok() { "ok" } else { "error" };
|
||||
turn_context
|
||||
.session_telemetry
|
||||
.counter("codex.mcp.call", /*inc*/ 1, &[("status", status)]);
|
||||
emit_mcp_call_metrics(
|
||||
turn_context.as_ref(),
|
||||
status,
|
||||
&tool_name,
|
||||
connector_id.as_deref(),
|
||||
connector_name.as_deref(),
|
||||
Some(duration),
|
||||
);
|
||||
|
||||
CallToolResult::from_result(result)
|
||||
}
|
||||
|
||||
fn emit_mcp_call_metrics(
|
||||
turn_context: &TurnContext,
|
||||
status: &str,
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
connector_name: Option<&str>,
|
||||
duration: Option<Duration>,
|
||||
) {
|
||||
let tags = mcp_call_metric_tags(status, tool_name, connector_id, connector_name);
|
||||
let tag_refs: Vec<(&str, &str)> = tags
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect();
|
||||
turn_context
|
||||
.session_telemetry
|
||||
.counter(MCP_CALL_COUNT_METRIC, /*inc*/ 1, &tag_refs);
|
||||
if let Some(duration) = duration {
|
||||
turn_context.session_telemetry.record_duration(
|
||||
MCP_CALL_DURATION_METRIC,
|
||||
duration,
|
||||
&tag_refs,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_call_metric_tags(
|
||||
status: &str,
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
connector_name: Option<&str>,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
let mut tags = vec![
|
||||
("status", sanitize_metric_tag_value(status)),
|
||||
("tool", sanitize_metric_tag_value(tool_name)),
|
||||
];
|
||||
if let Some(connector_id) = connector_id.filter(|connector_id| !connector_id.is_empty()) {
|
||||
tags.push(("connector_id", sanitize_metric_tag_value(connector_id)));
|
||||
}
|
||||
if let Some(connector_name) = connector_name.filter(|connector_name| !connector_name.is_empty())
|
||||
{
|
||||
tags.push(("connector_name", sanitize_metric_tag_value(connector_name)));
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
fn mcp_tool_call_span(
|
||||
session: &Session,
|
||||
turn_context: &TurnContext,
|
||||
fields: McpToolCallSpanFields<'_>,
|
||||
) -> Span {
|
||||
let transport = match fields.server_origin {
|
||||
Some("stdio") => "stdio",
|
||||
Some(_) => "streamable_http",
|
||||
None => "",
|
||||
};
|
||||
let span = tracing::info_span!(
|
||||
"mcp.tools.call",
|
||||
otel.kind = "client",
|
||||
rpc.system = "jsonrpc",
|
||||
rpc.method = "tools/call",
|
||||
mcp.server.name = fields.server_name,
|
||||
mcp.server.origin = fields.server_origin.unwrap_or(""),
|
||||
mcp.transport = transport,
|
||||
mcp.connector.id = fields.connector_id.unwrap_or(""),
|
||||
mcp.connector.name = fields.connector_name.unwrap_or(""),
|
||||
tool.name = fields.tool_name,
|
||||
tool.call_id = fields.call_id,
|
||||
conversation.id = %session.conversation_id,
|
||||
session.id = %session.conversation_id,
|
||||
turn.id = turn_context.sub_id.as_str(),
|
||||
server.address = Empty,
|
||||
server.port = Empty,
|
||||
);
|
||||
record_server_fields(&span, fields.server_origin);
|
||||
span
|
||||
}
|
||||
|
||||
struct McpToolCallSpanFields<'a> {
|
||||
server_name: &'a str,
|
||||
tool_name: &'a str,
|
||||
call_id: &'a str,
|
||||
server_origin: Option<&'a str>,
|
||||
connector_id: Option<&'a str>,
|
||||
connector_name: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn record_server_fields(span: &Span, url: Option<&str>) {
|
||||
let Some(url) = url else {
|
||||
return;
|
||||
};
|
||||
let Ok(parsed) = Url::parse(url) else {
|
||||
return;
|
||||
};
|
||||
if let Some(host) = parsed.host_str() {
|
||||
span.record("server.address", host);
|
||||
}
|
||||
if let Some(port) = parsed.port_or_known_default() {
|
||||
span.record("server.port", port as i64);
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_mark_thread_memory_mode_polluted(sess: &Session, turn_context: &TurnContext) {
|
||||
if !turn_context
|
||||
.config
|
||||
@@ -390,6 +570,27 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
|
||||
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
|
||||
|
||||
fn custom_mcp_tool_approval_mode(
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> AppToolApproval {
|
||||
turn_context
|
||||
.config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("mcp_servers"))
|
||||
.cloned()
|
||||
.and_then(|value| {
|
||||
HashMap::<String, crate::config::types::McpServerConfig>::deserialize(value).ok()
|
||||
})
|
||||
.and_then(|servers| servers.get(server).cloned())
|
||||
.and_then(|server| server.tools.get(tool_name).cloned())
|
||||
.and_then(|tool| tool.approval_mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_mcp_tool_call_request_meta(
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
@@ -459,7 +660,6 @@ const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params";
|
||||
const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display";
|
||||
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default";
|
||||
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow";
|
||||
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS: &str = "mcp_tool_call__full_access";
|
||||
|
||||
pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool {
|
||||
question_id
|
||||
@@ -494,11 +694,14 @@ async fn maybe_request_mcp_tool_approval(
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
approval_mode: AppToolApproval,
|
||||
) -> Option<McpToolApprovalDecision> {
|
||||
if is_full_access_mode(turn_context) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
|
||||
let approval_required = requires_mcp_tool_approval(annotations);
|
||||
let mut monitor_reason = None;
|
||||
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve
|
||||
|| (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context));
|
||||
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve;
|
||||
|
||||
if auto_approved_by_policy {
|
||||
if !approval_required {
|
||||
@@ -711,12 +914,7 @@ fn persistent_mcp_tool_approval_key(
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
approval_mode: AppToolApproval,
|
||||
) -> Option<McpToolApprovalKey> {
|
||||
if invocation.server != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return None;
|
||||
}
|
||||
|
||||
session_mcp_tool_approval_key(invocation, metadata, approval_mode)
|
||||
.filter(|key| key.connector_id.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn build_guardian_mcp_tool_review_request(
|
||||
@@ -764,16 +962,12 @@ fn is_full_access_mode(turn_context: &TurnContext) -> bool {
|
||||
|
||||
fn mcp_tool_approval_callsite_mode(
|
||||
approval_mode: AppToolApproval,
|
||||
turn_context: &TurnContext,
|
||||
_turn_context: &TurnContext,
|
||||
) -> &'static str {
|
||||
match approval_mode {
|
||||
AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW,
|
||||
AppToolApproval::Auto | AppToolApproval::Prompt => {
|
||||
if approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context) {
|
||||
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS
|
||||
} else {
|
||||
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT
|
||||
}
|
||||
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1255,21 +1449,25 @@ async fn maybe_persist_mcp_tool_approval(
|
||||
turn_context: &TurnContext,
|
||||
key: McpToolApprovalKey,
|
||||
) {
|
||||
let Some(connector_id) = key.connector_id.clone() else {
|
||||
remember_mcp_tool_approval(sess, key).await;
|
||||
return;
|
||||
};
|
||||
let tool_name = key.tool_name.clone();
|
||||
|
||||
if let Err(err) =
|
||||
let persist_result = if key.server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
let Some(connector_id) = key.connector_id.clone() else {
|
||||
remember_mcp_tool_approval(sess, key).await;
|
||||
return;
|
||||
};
|
||||
persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name)
|
||||
.await
|
||||
{
|
||||
} else {
|
||||
persist_custom_mcp_tool_approval(&turn_context.config, &key.server, &tool_name).await
|
||||
};
|
||||
|
||||
if let Err(err) = persist_result {
|
||||
error!(
|
||||
error = %err,
|
||||
connector_id,
|
||||
server = key.server,
|
||||
tool_name,
|
||||
"failed to persist codex app tool approval"
|
||||
"failed to persist MCP tool approval"
|
||||
);
|
||||
remember_mcp_tool_approval(sess, key).await;
|
||||
return;
|
||||
@@ -1299,6 +1497,67 @@ async fn persist_codex_app_tool_approval(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn persist_custom_mcp_tool_approval(
|
||||
config: &Config,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let config_folder = if let Some(project_config_folder) =
|
||||
project_mcp_tool_approval_config_folder(config, server)
|
||||
{
|
||||
project_config_folder
|
||||
} else {
|
||||
let servers = load_global_mcp_servers(&config.codex_home).await?;
|
||||
if !servers.contains_key(server) {
|
||||
anyhow::bail!("MCP server `{server}` is not configured in config.toml");
|
||||
}
|
||||
config.codex_home.clone()
|
||||
};
|
||||
|
||||
ConfigEditsBuilder::new(&config_folder)
|
||||
.with_edits([ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"mcp_servers".to_string(),
|
||||
server.to_string(),
|
||||
"tools".to_string(),
|
||||
tool_name.to_string(),
|
||||
"approval_mode".to_string(),
|
||||
],
|
||||
value: value("approve"),
|
||||
}])
|
||||
.apply()
|
||||
.await
|
||||
}
|
||||
|
||||
fn project_mcp_tool_approval_config_folder(config: &Config, server: &str) -> Option<PathBuf> {
|
||||
config
|
||||
.config_layer_stack
|
||||
.layers_high_to_low()
|
||||
.into_iter()
|
||||
.find_map(|layer| {
|
||||
if !matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let servers = layer
|
||||
.config
|
||||
.as_table()
|
||||
.and_then(|table| table.get("mcp_servers"))
|
||||
.cloned()
|
||||
.and_then(|value| {
|
||||
HashMap::<String, crate::config::types::McpServerConfig>::deserialize(value)
|
||||
.ok()
|
||||
})?;
|
||||
if servers.contains_key(server) {
|
||||
layer
|
||||
.config_folder()
|
||||
.map(|folder| folder.as_path().to_path_buf())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
|
||||
let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint);
|
||||
if destructive_hint == Some(true) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use super::*;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::config::ApprovalsReviewer;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::types::AppConfig;
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerToolConfig;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -18,6 +21,10 @@ use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use tracing::Instrument;
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::fmt::format::FmtSpan;
|
||||
use tracing_test::internal::MockWriter;
|
||||
|
||||
fn annotations(
|
||||
read_only: Option<bool>,
|
||||
@@ -119,6 +126,57 @@ fn approval_question_text_prepends_safety_reason() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_call_span_records_expected_fields() {
|
||||
let buffer: &'static std::sync::Mutex<Vec<u8>> =
|
||||
Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_level(true)
|
||||
.with_ansi(false)
|
||||
.with_max_level(Level::TRACE)
|
||||
.with_span_events(FmtSpan::FULL)
|
||||
.with_writer(MockWriter::new(buffer))
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
async {}
|
||||
.instrument(mcp_tool_call_span(
|
||||
&session,
|
||||
&turn_context,
|
||||
McpToolCallSpanFields {
|
||||
server_name: "rmcp",
|
||||
tool_name: "echo",
|
||||
call_id: "call-123",
|
||||
server_origin: Some("https://example.com:8443/mcp"),
|
||||
connector_id: Some("calendar"),
|
||||
connector_name: Some("Calendar"),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
|
||||
let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()).expect("utf8 logs");
|
||||
assert!(
|
||||
logs.contains("mcp.tools.call{otel.kind=\"client\"")
|
||||
&& logs.contains("rpc.system=\"jsonrpc\"")
|
||||
&& logs.contains("rpc.method=\"tools/call\"")
|
||||
&& logs.contains("mcp.server.name=\"rmcp\"")
|
||||
&& logs.contains("mcp.server.origin=\"https://example.com:8443/mcp\"")
|
||||
&& logs.contains("mcp.transport=\"streamable_http\"")
|
||||
&& logs.contains("mcp.connector.id=\"calendar\"")
|
||||
&& logs.contains("mcp.connector.name=\"Calendar\"")
|
||||
&& logs.contains("tool.name=\"echo\"")
|
||||
&& logs.contains("tool.call_id=\"call-123\"")
|
||||
&& logs.contains("server.address=\"example.com\"")
|
||||
&& logs.contains("server.port=8443")
|
||||
&& logs.contains("conversation.id=")
|
||||
&& logs.contains("session.id=")
|
||||
&& logs.contains("turn.id="),
|
||||
"missing MCP tool span fields\nlogs:\n{logs}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_elicitation_request_uses_message_override_and_preserves_tool_params_keys() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -323,13 +381,13 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_mcp_tool_question_offers_session_remember_without_always_allow() {
|
||||
fn custom_mcp_tool_question_offers_session_remember_and_always_allow() {
|
||||
let question = build_mcp_tool_approval_question(
|
||||
"q".to_string(),
|
||||
"custom_server",
|
||||
"run_action",
|
||||
None,
|
||||
prompt_options(true, false),
|
||||
prompt_options(true, true),
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -343,13 +401,14 @@ fn custom_mcp_tool_question_offers_session_remember_without_always_allow() {
|
||||
vec![
|
||||
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
|
||||
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
|
||||
MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
|
||||
MCP_TOOL_APPROVAL_CANCEL.to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_servers_keep_session_remember_without_persistent_approval() {
|
||||
fn custom_servers_support_session_and_persistent_approval() {
|
||||
let invocation = McpInvocation {
|
||||
server: "custom_server".to_string(),
|
||||
tool: "run_action".to_string(),
|
||||
@@ -363,11 +422,11 @@ fn custom_servers_keep_session_remember_without_persistent_approval() {
|
||||
|
||||
assert_eq!(
|
||||
session_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto),
|
||||
Some(expected)
|
||||
Some(expected.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
persistent_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto),
|
||||
None
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -557,7 +616,7 @@ fn approval_elicitation_meta_marks_tool_approvals() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_elicitation_meta_keeps_session_persist_behavior_for_custom_servers() {
|
||||
fn approval_elicitation_meta_merges_session_and_always_persist_for_custom_servers() {
|
||||
assert_eq!(
|
||||
build_mcp_tool_approval_elicitation_meta(
|
||||
"custom_server",
|
||||
@@ -570,11 +629,14 @@ fn approval_elicitation_meta_keeps_session_persist_behavior_for_custom_servers()
|
||||
)),
|
||||
Some(&serde_json::json!({"id": 1})),
|
||||
None,
|
||||
prompt_options(true, false),
|
||||
prompt_options(true, true),
|
||||
),
|
||||
Some(serde_json::json!({
|
||||
MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL,
|
||||
MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION,
|
||||
MCP_TOOL_APPROVAL_PERSIST_KEY: [
|
||||
MCP_TOOL_APPROVAL_PERSIST_SESSION,
|
||||
MCP_TOOL_APPROVAL_PERSIST_ALWAYS,
|
||||
],
|
||||
MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action",
|
||||
MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.",
|
||||
MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: {
|
||||
@@ -788,8 +850,8 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_access() {
|
||||
let (_session, mut turn_context) = make_session_and_context().await;
|
||||
async fn approval_callsite_mode_distinguishes_default_and_always_allow() {
|
||||
let (_session, turn_context) = make_session_and_context().await;
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context),
|
||||
@@ -803,20 +865,6 @@ async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_acce
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context),
|
||||
"mcp_tool_call__always_allow"
|
||||
);
|
||||
|
||||
turn_context
|
||||
.approval_policy
|
||||
.set(AskForApproval::Never)
|
||||
.expect("test setup should allow updating approval policy");
|
||||
turn_context
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::DangerFullAccess)
|
||||
.expect("test setup should allow updating sandbox policy");
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context),
|
||||
"mcp_tool_call__full_access"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -937,6 +985,41 @@ async fn persist_codex_app_tool_approval_writes_tool_override() {
|
||||
assert!(contents.contains("[apps.calendar.tools.\"calendar/list_events\"]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_custom_mcp_tool_approval_writes_tool_override() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(
|
||||
tmp.path().join(CONFIG_TOML_FILE),
|
||||
"[mcp_servers.docs]\ncommand = \"docs-server\"\n",
|
||||
)
|
||||
.expect("seed config");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
persist_custom_mcp_tool_approval(&config, "docs", "search")
|
||||
.await
|
||||
.expect("persist approval");
|
||||
|
||||
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
let tool = parsed
|
||||
.mcp_servers
|
||||
.get("docs")
|
||||
.and_then(|server| server.tools.get("search"))
|
||||
.expect("docs/search tool config exists");
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
&McpServerToolConfig {
|
||||
approval_mode: Some(AppToolApproval::Approve),
|
||||
}
|
||||
);
|
||||
assert!(contents.contains("[mcp_servers.docs.tools.search]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_persist_mcp_tool_approval_reloads_session_config() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -976,6 +1059,104 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config() {
|
||||
assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_server() {
|
||||
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");
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
"[mcp_servers.docs]\ncommand = \"docs-server\"\n",
|
||||
)
|
||||
.expect("seed config");
|
||||
let key = McpToolApprovalKey {
|
||||
server: "docs".to_string(),
|
||||
connector_id: None,
|
||||
tool_name: "search".to_string(),
|
||||
};
|
||||
|
||||
maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;
|
||||
|
||||
let config = session.get_config().await;
|
||||
let mcp_servers_toml = config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("mcp_servers"))
|
||||
.cloned()
|
||||
.expect("mcp_servers table");
|
||||
let mcp_servers = HashMap::<String, McpServerConfig>::deserialize(mcp_servers_toml)
|
||||
.expect("deserialize MCP servers");
|
||||
let tool = mcp_servers
|
||||
.get("docs")
|
||||
.and_then(|server| server.tools.get("search"))
|
||||
.expect("docs/search tool config exists");
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
&McpServerToolConfig {
|
||||
approval_mode: Some(AppToolApproval::Approve),
|
||||
}
|
||||
);
|
||||
assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_server() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
let project_dir = tempdir().expect("tempdir");
|
||||
std::fs::write(project_dir.path().join(".git"), "gitdir: nowhere").expect("seed git marker");
|
||||
let project_codex_dir = project_dir.path().join(".codex");
|
||||
std::fs::create_dir_all(&project_codex_dir).expect("create project .codex dir");
|
||||
std::fs::write(
|
||||
project_codex_dir.join(CONFIG_TOML_FILE),
|
||||
"[mcp_servers.docs]\ncommand = \"docs-server\"\n",
|
||||
)
|
||||
.expect("seed project config");
|
||||
ConfigEditsBuilder::new(&codex_home)
|
||||
.set_project_trust_level(
|
||||
project_dir.path(),
|
||||
codex_protocol::config_types::TrustLevel::Trusted,
|
||||
)
|
||||
.apply()
|
||||
.await
|
||||
.expect("trust project");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(project_dir.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("load project config");
|
||||
turn_context.cwd = config.cwd.clone();
|
||||
turn_context.config = Arc::new(config);
|
||||
let key = McpToolApprovalKey {
|
||||
server: "docs".to_string(),
|
||||
connector_id: None,
|
||||
tool_name: "search".to_string(),
|
||||
};
|
||||
|
||||
maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;
|
||||
|
||||
let contents = std::fs::read_to_string(project_codex_dir.join(CONFIG_TOML_FILE))
|
||||
.expect("read project config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse project config");
|
||||
let tool = parsed
|
||||
.mcp_servers
|
||||
.get("docs")
|
||||
.and_then(|server| server.tools.get("search"))
|
||||
.expect("docs/search tool config exists");
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
&McpServerToolConfig {
|
||||
approval_mode: Some(AppToolApproval::Approve),
|
||||
}
|
||||
);
|
||||
assert!(contents.contains("[mcp_servers.docs.tools.search]"));
|
||||
assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -1078,6 +1259,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let invocation = McpInvocation {
|
||||
server: "docs".to_string(),
|
||||
tool: "dangerous_tool".to_string(),
|
||||
arguments: Some(serde_json::json!({ "id": 1 })),
|
||||
};
|
||||
let metadata = McpToolApprovalMetadata {
|
||||
annotations: Some(annotations(Some(false), Some(true), Some(true))),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
connector_description: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-2-custom",
|
||||
&invocation,
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
|
||||
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
|
||||
use wiremock::Mock;
|
||||
@@ -1148,7 +1398,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -1169,7 +1419,7 @@ async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(1)
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
@@ -1206,22 +1456,23 @@ async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
codex_apps_meta: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-2",
|
||||
&invocation,
|
||||
Some(&metadata),
|
||||
for approval_mode in [
|
||||
AppToolApproval::Auto,
|
||||
)
|
||||
.await;
|
||||
AppToolApproval::Prompt,
|
||||
AppToolApproval::Approve,
|
||||
] {
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-2",
|
||||
&invocation,
|
||||
Some(&metadata),
|
||||
approval_mode,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
|
||||
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
|
||||
))
|
||||
);
|
||||
assert_eq!(decision, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::phase_one;
|
||||
use crate::memories::storage::rollout_summary_file_stem_from_parts;
|
||||
use askama::Template;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_state::Phase2InputSelection;
|
||||
use codex_state::Stage1Output;
|
||||
use codex_state::Stage1OutputRef;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use codex_utils_template::Template;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
use tokio::fs;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "memories/consolidation.md", escape = "none")]
|
||||
struct ConsolidationPromptTemplate<'a> {
|
||||
memory_root: &'a str,
|
||||
phase2_input_selection: &'a str,
|
||||
}
|
||||
static CONSOLIDATION_PROMPT_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
|
||||
parse_embedded_template(
|
||||
include_str!("../../templates/memories/consolidation.md"),
|
||||
"memories/consolidation.md",
|
||||
)
|
||||
});
|
||||
static STAGE_ONE_INPUT_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
|
||||
parse_embedded_template(
|
||||
include_str!("../../templates/memories/stage_one_input.md"),
|
||||
"memories/stage_one_input.md",
|
||||
)
|
||||
});
|
||||
static MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
|
||||
parse_embedded_template(
|
||||
include_str!("../../templates/memories/read_path.md"),
|
||||
"memories/read_path.md",
|
||||
)
|
||||
});
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "memories/stage_one_input.md", escape = "none")]
|
||||
struct StageOneInputTemplate<'a> {
|
||||
rollout_path: &'a str,
|
||||
rollout_cwd: &'a str,
|
||||
rollout_contents: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "memories/read_path.md", escape = "none")]
|
||||
struct MemoryToolDeveloperInstructionsTemplate<'a> {
|
||||
base_path: &'a str,
|
||||
memory_summary: &'a str,
|
||||
fn parse_embedded_template(source: &'static str, template_name: &str) -> Template {
|
||||
match Template::parse(source) {
|
||||
Ok(template) => template,
|
||||
Err(err) => panic!("embedded template {template_name} is invalid: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the consolidation subagent prompt for a specific memory root.
|
||||
@@ -41,11 +46,12 @@ pub(super) fn build_consolidation_prompt(
|
||||
) -> String {
|
||||
let memory_root = memory_root.display().to_string();
|
||||
let phase2_input_selection = render_phase2_input_selection(selection);
|
||||
let template = ConsolidationPromptTemplate {
|
||||
memory_root: &memory_root,
|
||||
phase2_input_selection: &phase2_input_selection,
|
||||
};
|
||||
template.render().unwrap_or_else(|err| {
|
||||
CONSOLIDATION_PROMPT_TEMPLATE
|
||||
.render([
|
||||
("memory_root", memory_root.as_str()),
|
||||
("phase2_input_selection", phase2_input_selection.as_str()),
|
||||
])
|
||||
.unwrap_or_else(|err| {
|
||||
warn!("failed to render memories consolidation prompt template: {err}");
|
||||
format!(
|
||||
"## Memory Phase 2 (Consolidation)\nConsolidate Codex memories in: {memory_root}\n\n{phase2_input_selection}"
|
||||
@@ -144,12 +150,11 @@ pub(super) fn build_stage_one_input_message(
|
||||
|
||||
let rollout_path = rollout_path.display().to_string();
|
||||
let rollout_cwd = rollout_cwd.display().to_string();
|
||||
Ok(StageOneInputTemplate {
|
||||
rollout_path: &rollout_path,
|
||||
rollout_cwd: &rollout_cwd,
|
||||
rollout_contents: &truncated_rollout_contents,
|
||||
}
|
||||
.render()?)
|
||||
Ok(STAGE_ONE_INPUT_TEMPLATE.render([
|
||||
("rollout_path", rollout_path.as_str()),
|
||||
("rollout_cwd", rollout_cwd.as_str()),
|
||||
("rollout_contents", truncated_rollout_contents.as_str()),
|
||||
])?)
|
||||
}
|
||||
|
||||
/// Build prompt used for read path. This prompt must be added to the developer instructions. In
|
||||
@@ -171,11 +176,12 @@ pub(crate) async fn build_memory_tool_developer_instructions(codex_home: &Path)
|
||||
return None;
|
||||
}
|
||||
let base_path = base_path.display().to_string();
|
||||
let template = MemoryToolDeveloperInstructionsTemplate {
|
||||
base_path: &base_path,
|
||||
memory_summary: &memory_summary,
|
||||
};
|
||||
template.render().ok()
|
||||
MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_TEMPLATE
|
||||
.render([
|
||||
("base_path", base_path.as_str()),
|
||||
("memory_summary", memory_summary.as_str()),
|
||||
])
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use crate::models_manager::model_info::model_info_from_slug;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
use tokio::fs as tokio_fs;
|
||||
|
||||
#[test]
|
||||
fn build_stage_one_input_message_truncates_rollout_using_model_context_window() {
|
||||
@@ -49,3 +52,43 @@ fn build_stage_one_input_message_uses_default_limit_when_model_context_window_mi
|
||||
|
||||
assert!(message.contains(&expected_truncated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_consolidation_prompt_renders_embedded_template() {
|
||||
let prompt =
|
||||
build_consolidation_prompt(Path::new("/tmp/memories"), &Phase2InputSelection::default());
|
||||
|
||||
assert!(prompt.contains("Folder structure (under /tmp/memories/):"));
|
||||
assert!(prompt.contains("**Diff since last consolidation:**"));
|
||||
assert!(prompt.contains("- selected inputs this run: 0"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_memory_tool_developer_instructions_renders_embedded_template() {
|
||||
let temp = tempdir().unwrap();
|
||||
let codex_home = temp.path();
|
||||
let memories_dir = codex_home.join("memories");
|
||||
tokio_fs::create_dir_all(&memories_dir).await.unwrap();
|
||||
tokio_fs::write(
|
||||
memories_dir.join("memory_summary.md"),
|
||||
"Short memory summary for tests.",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let instructions = build_memory_tool_developer_instructions(codex_home)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(instructions.contains(&format!(
|
||||
"- {}/memory_summary.md (already provided below; do NOT open again)",
|
||||
memories_dir.display()
|
||||
)));
|
||||
assert!(instructions.contains("Short memory summary for tests."));
|
||||
assert_eq!(
|
||||
instructions
|
||||
.matches("========= MEMORY_SUMMARY BEGINS =========")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
@@ -484,6 +484,9 @@ mod phase2 {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
std::sync::Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let (mut session, _turn_context) = make_session_and_context().await;
|
||||
session.services.state_db = Some(Arc::clone(&state_db));
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub(crate) mod versions;
|
||||
@@ -1,2 +0,0 @@
|
||||
/// Pinned versions for package-manager-backed installs.
|
||||
pub(crate) const ARTIFACT_RUNTIME: &str = "2.5.6";
|
||||
@@ -16,10 +16,9 @@ const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
|
||||
"slack@openai-curated",
|
||||
"gmail@openai-curated",
|
||||
"google-calendar@openai-curated",
|
||||
"google-docs@openai-curated",
|
||||
"google-drive@openai-curated",
|
||||
"google-sheets@openai-curated",
|
||||
"google-slides@openai-curated",
|
||||
"linear@openai-curated",
|
||||
"figma@openai-curated",
|
||||
];
|
||||
|
||||
pub(crate) fn list_tool_suggest_discoverable_plugins(
|
||||
|
||||
@@ -78,6 +78,7 @@ const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
pub const OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated";
|
||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration =
|
||||
std::time::Duration::from_secs(60 * 60 * 3);
|
||||
@@ -889,7 +890,13 @@ impl PluginsManager {
|
||||
(!plugins.is_empty()).then_some(ConfiguredMarketplace {
|
||||
name: marketplace.name,
|
||||
path: marketplace.path,
|
||||
interface: marketplace.interface,
|
||||
interface: if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
Some(MarketplaceInterface {
|
||||
display_name: Some(OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()),
|
||||
})
|
||||
} else {
|
||||
marketplace.interface
|
||||
},
|
||||
plugins,
|
||||
})
|
||||
})
|
||||
@@ -972,7 +979,11 @@ impl PluginsManager {
|
||||
mcp_server_names.dedup();
|
||||
|
||||
Ok(PluginReadOutcome {
|
||||
marketplace_name: marketplace.name,
|
||||
marketplace_name: if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()
|
||||
} else {
|
||||
marketplace.name
|
||||
},
|
||||
marketplace_path: marketplace.path,
|
||||
plugin: PluginDetail {
|
||||
id: plugin_key.clone(),
|
||||
|
||||
@@ -164,6 +164,7 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]),
|
||||
apps: vec![AppConnectorId("connector_example".to_string())],
|
||||
@@ -483,6 +484,7 @@ fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)])
|
||||
);
|
||||
@@ -586,6 +588,7 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)])
|
||||
);
|
||||
@@ -735,6 +738,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
};
|
||||
let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin {
|
||||
config_name: config_name.to_string(),
|
||||
@@ -1386,7 +1390,7 @@ plugins = true
|
||||
path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap(),
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("ChatGPT Official".to_string()),
|
||||
display_name: Some(OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()),
|
||||
}),
|
||||
plugins: vec![ConfiguredMarketplacePlugin {
|
||||
id: "linear@openai-curated".to_string(),
|
||||
|
||||
@@ -252,3 +252,37 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() {
|
||||
SafetyCheck::AskUser,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_project_dot_codex_config_requires_approval() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path().to_path_buf();
|
||||
let action =
|
||||
ApplyPatchAction::new_add_for_test(&cwd.join(".codex").join("config.toml"), "".to_string());
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let file_system_sandbox_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &cwd);
|
||||
|
||||
assert!(!is_write_patch_constrained_to_writable_paths(
|
||||
&action,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
));
|
||||
assert_eq!(
|
||||
assess_patch_safety(
|
||||
&action,
|
||||
AskForApproval::OnRequest,
|
||||
&sandbox_policy,
|
||||
&file_system_sandbox_policy,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
),
|
||||
SafetyCheck::AskUser,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions;
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -24,14 +24,13 @@ pub async fn spawn_command_under_seatbelt(
|
||||
network: Option<&NetworkProxy>,
|
||||
mut env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
let args = create_seatbelt_command_args_for_policies_with_extensions(
|
||||
let args = create_seatbelt_command_args_for_policies(
|
||||
command,
|
||||
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd),
|
||||
NetworkSandboxPolicy::from(sandbox_policy),
|
||||
sandbox_policy_cwd,
|
||||
/*enforce_managed_network*/ false,
|
||||
network,
|
||||
/*extensions*/ None,
|
||||
);
|
||||
let arg0 = None;
|
||||
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::AuthManager;
|
||||
@@ -16,14 +15,12 @@ use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::state_db::StateDbHandle;
|
||||
use crate::tools::code_mode::CodeModeService;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -49,8 +46,6 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) models_manager: Arc<ModelsManager>,
|
||||
pub(crate) session_telemetry: SessionTelemetry,
|
||||
pub(crate) tool_approvals: Mutex<ApprovalStore>,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) execve_session_approvals: RwLock<HashMap<AbsolutePathBuf, ExecveSessionApproval>>,
|
||||
pub(crate) skills_manager: Arc<SkillsManager>,
|
||||
pub(crate) plugins_manager: Arc<PluginsManager>,
|
||||
pub(crate) mcp_manager: Arc<McpManager>,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
@@ -60,8 +61,14 @@ pub fn thread_manager_with_models_provider_and_home(
|
||||
auth: CodexAuth,
|
||||
provider: ModelProviderInfo,
|
||||
codex_home: PathBuf,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
) -> ThreadManager {
|
||||
ThreadManager::with_models_provider_and_home_for_tests(auth, provider, codex_home)
|
||||
ThreadManager::with_models_provider_and_home_for_tests(
|
||||
auth,
|
||||
provider,
|
||||
codex_home,
|
||||
environment_manager,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn start_thread_with_user_shell_override(
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::skills_watcher::SkillsWatcherEvent;
|
||||
use crate::tasks::interrupted_turn_history_marker;
|
||||
use codex_app_server_protocol::ThreadHistoryBuilder;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
#[cfg(test)]
|
||||
@@ -200,6 +201,7 @@ pub(crate) struct ThreadManagerState {
|
||||
thread_created_tx: broadcast::Sender<ThreadId>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
@@ -215,6 +217,7 @@ impl ThreadManager {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
session_source: SessionSource,
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
) -> Self {
|
||||
let codex_home = config.codex_home.clone();
|
||||
let restriction_product = session_source.restriction_product();
|
||||
@@ -246,6 +249,7 @@ impl ThreadManager {
|
||||
collaboration_modes_config,
|
||||
openai_models_provider,
|
||||
)),
|
||||
environment_manager,
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
@@ -272,8 +276,12 @@ impl ThreadManager {
|
||||
));
|
||||
std::fs::create_dir_all(&codex_home)
|
||||
.unwrap_or_else(|err| panic!("temp codex home dir create failed: {err}"));
|
||||
let mut manager =
|
||||
Self::with_models_provider_and_home_for_tests(auth, provider, codex_home.clone());
|
||||
let mut manager = Self::with_models_provider_and_home_for_tests(
|
||||
auth,
|
||||
provider,
|
||||
codex_home.clone(),
|
||||
Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
|
||||
);
|
||||
manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home });
|
||||
manager
|
||||
}
|
||||
@@ -284,6 +292,7 @@ impl ThreadManager {
|
||||
auth: CodexAuth,
|
||||
provider: ModelProviderInfo,
|
||||
codex_home: PathBuf,
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
) -> Self {
|
||||
set_thread_manager_test_mode_for_tests(/*enabled*/ true);
|
||||
let auth_manager = AuthManager::from_auth_for_testing(auth);
|
||||
@@ -309,6 +318,7 @@ impl ThreadManager {
|
||||
auth_manager.clone(),
|
||||
provider,
|
||||
)),
|
||||
environment_manager,
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
@@ -844,6 +854,7 @@ impl ThreadManagerState {
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager: Arc::clone(&self.models_manager),
|
||||
environment_manager: Arc::clone(&self.environment_manager),
|
||||
skills_manager: Arc::clone(&self.skills_manager),
|
||||
plugins_manager: Arc::clone(&self.plugins_manager),
|
||||
mcp_manager: Arc::clone(&self.mcp_manager),
|
||||
|
||||
@@ -244,6 +244,9 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let thread_1 = manager
|
||||
.start_thread(config.clone())
|
||||
@@ -292,6 +295,9 @@ async fn new_uses_configured_openai_provider_for_model_refresh() {
|
||||
auth_manager,
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
|
||||
let _ = manager.list_models(RefreshStrategy::Online).await;
|
||||
@@ -419,6 +425,9 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
@@ -516,6 +525,9 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
@@ -602,6 +614,9 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
|
||||
auth_manager.clone(),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
|
||||
let source = manager
|
||||
|
||||
@@ -84,6 +84,10 @@ pub trait ToolOutput: Send {
|
||||
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
|
||||
|
||||
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
|
||||
None
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
|
||||
response_input_to_code_mode_result(self.to_response_item("", payload))
|
||||
}
|
||||
@@ -158,6 +162,7 @@ impl ToolOutput for ToolSearchOutput {
|
||||
pub struct FunctionToolOutput {
|
||||
pub body: Vec<FunctionCallOutputContentItem>,
|
||||
pub success: Option<bool>,
|
||||
pub post_tool_use_response: Option<JsonValue>,
|
||||
}
|
||||
|
||||
impl FunctionToolOutput {
|
||||
@@ -165,6 +170,7 @@ impl FunctionToolOutput {
|
||||
Self {
|
||||
body: vec![FunctionCallOutputContentItem::InputText { text }],
|
||||
success,
|
||||
post_tool_use_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +181,7 @@ impl FunctionToolOutput {
|
||||
Self {
|
||||
body: content,
|
||||
success,
|
||||
post_tool_use_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +204,10 @@ impl ToolOutput for FunctionToolOutput {
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
function_tool_response(call_id, payload, self.body.clone(), self.success)
|
||||
}
|
||||
|
||||
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
|
||||
self.post_tool_use_response.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApplyPatchToolOutput {
|
||||
@@ -305,6 +316,14 @@ impl ToolOutput for ExecCommandToolOutput {
|
||||
)
|
||||
}
|
||||
|
||||
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
|
||||
if self.process_id.is_some() || self.session_command.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(JsonValue::String(self.truncated_output()))
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
#[derive(Serialize)]
|
||||
struct UnifiedExecCodeModeResult {
|
||||
|
||||
@@ -205,7 +205,6 @@ impl ToolHandler for ApplyPatchHandler {
|
||||
permissions_preapproved: effective_additional_permissions
|
||||
.permissions_preapproved,
|
||||
timeout_ms: None,
|
||||
codex_exe: turn.codex_linux_sandbox_exe.clone(),
|
||||
};
|
||||
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
@@ -308,7 +307,6 @@ pub(crate) async fn intercept_apply_patch(
|
||||
permissions_preapproved: effective_additional_permissions
|
||||
.permissions_preapproved,
|
||||
timeout_ms,
|
||||
codex_exe: turn.codex_linux_sandbox_exe.clone(),
|
||||
};
|
||||
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_artifacts::ArtifactBuildRequest;
|
||||
use codex_artifacts::ArtifactCommandOutput;
|
||||
use codex_artifacts::ArtifactRuntimeManager;
|
||||
use codex_artifacts::ArtifactRuntimeManagerConfig;
|
||||
use codex_artifacts::ArtifactsClient;
|
||||
use codex_artifacts::ArtifactsError;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::packages::versions;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventFailure;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_features::Feature;
|
||||
|
||||
const ARTIFACTS_TOOL_NAME: &str = "artifacts";
|
||||
const ARTIFACT_TOOL_PRAGMA_PREFIX: &str = "// codex-artifact-tool:";
|
||||
const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct ArtifactsHandler;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ArtifactsToolArgs {
|
||||
source: String,
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ArtifactsHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Custom { .. })
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.enabled(Feature::Artifact) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let args = match payload {
|
||||
ToolPayload::Custom { input } => parse_freeform_args(&input)?,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts expects freeform JavaScript input authored against the preloaded @oai/artifact-tool exports".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let client = ArtifactsClient::from_runtime_manager(default_runtime_manager(
|
||||
turn.config.codex_home.clone(),
|
||||
));
|
||||
|
||||
let started_at = Instant::now();
|
||||
emit_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await;
|
||||
|
||||
let result = client
|
||||
.execute_build(ArtifactBuildRequest {
|
||||
source: args.source,
|
||||
cwd: turn.cwd.to_path_buf(),
|
||||
timeout: Some(Duration::from_millis(
|
||||
args.timeout_ms
|
||||
.unwrap_or(DEFAULT_EXECUTION_TIMEOUT.as_millis() as u64),
|
||||
)),
|
||||
env: Default::default(),
|
||||
})
|
||||
.await;
|
||||
|
||||
let (success, output) = match result {
|
||||
Ok(output) => (output.success(), output),
|
||||
Err(error) => (false, error_output(&error)),
|
||||
};
|
||||
|
||||
emit_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
&output,
|
||||
started_at.elapsed(),
|
||||
success,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(FunctionToolOutput::from_text(
|
||||
format_artifact_output(&output),
|
||||
Some(success),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_freeform_args(input: &str) -> Result<ArtifactsToolArgs, FunctionCallError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts expects raw JavaScript source text (non-empty) authored against the preloaded @oai/artifact-tool exports. Provide JS only, optionally with first-line `// codex-artifact-tool: timeout_ms=15000`."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut args = ArtifactsToolArgs {
|
||||
source: input.to_string(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
|
||||
let mut lines = input.splitn(2, '\n');
|
||||
let first_line = lines.next().unwrap_or_default();
|
||||
let rest = lines.next().unwrap_or_default();
|
||||
let trimmed = first_line.trim_start();
|
||||
let Some(pragma) = parse_pragma_prefix(trimmed) else {
|
||||
reject_json_or_quoted_source(&args.source)?;
|
||||
return Ok(args);
|
||||
};
|
||||
|
||||
let mut timeout_ms = None;
|
||||
let directive = pragma.trim();
|
||||
if !directive.is_empty() {
|
||||
for token in directive.split_whitespace() {
|
||||
let (key, value) = token.split_once('=').ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"artifacts pragma expects space-separated key=value pairs (supported keys: timeout_ms); got `{token}`"
|
||||
))
|
||||
})?;
|
||||
match key {
|
||||
"timeout_ms" => {
|
||||
if timeout_ms.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts pragma specifies timeout_ms more than once".to_string(),
|
||||
));
|
||||
}
|
||||
let parsed = value.parse::<u64>().map_err(|_| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"artifacts pragma timeout_ms must be an integer; got `{value}`"
|
||||
))
|
||||
})?;
|
||||
timeout_ms = Some(parsed);
|
||||
}
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"artifacts pragma only supports timeout_ms; got `{key}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rest.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts pragma must be followed by JavaScript source on subsequent lines"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
reject_json_or_quoted_source(rest)?;
|
||||
args.source = rest.to_string();
|
||||
args.timeout_ms = timeout_ms;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> {
|
||||
let trimmed = code.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts expects raw JavaScript source, not markdown code fences. Resend plain JS only (optional first line `// codex-artifact-tool: ...`)."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) else {
|
||||
return Ok(());
|
||||
};
|
||||
match value {
|
||||
JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel(
|
||||
"artifacts is a freeform tool and expects raw JavaScript source authored against the preloaded @oai/artifact-tool exports. Resend plain JS only (optional first line `// codex-artifact-tool: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
|
||||
.to_string(),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pragma_prefix(line: &str) -> Option<&str> {
|
||||
line.strip_prefix(ARTIFACT_TOOL_PRAGMA_PREFIX)
|
||||
}
|
||||
|
||||
fn default_runtime_manager(codex_home: std::path::PathBuf) -> ArtifactRuntimeManager {
|
||||
ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::with_default_release(
|
||||
codex_home,
|
||||
versions::ARTIFACT_RUNTIME,
|
||||
))
|
||||
}
|
||||
|
||||
async fn emit_exec_begin(session: &Session, turn: &TurnContext, call_id: &str) {
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec![ARTIFACTS_TOOL_NAME.to_string()],
|
||||
turn.cwd.to_path_buf(),
|
||||
ExecCommandSource::Agent,
|
||||
/*freeform*/ true,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, /*turn_diff_tracker*/ None);
|
||||
emitter.emit(ctx, ToolEventStage::Begin).await;
|
||||
}
|
||||
|
||||
async fn emit_exec_end(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
call_id: &str,
|
||||
output: &ArtifactCommandOutput,
|
||||
duration: Duration,
|
||||
success: bool,
|
||||
) {
|
||||
let exec_output = ExecToolCallOutput {
|
||||
exit_code: output.exit_code.unwrap_or(1),
|
||||
stdout: StreamOutput::new(output.stdout.clone()),
|
||||
stderr: StreamOutput::new(output.stderr.clone()),
|
||||
aggregated_output: StreamOutput::new(format_artifact_output(output)),
|
||||
duration,
|
||||
timed_out: false,
|
||||
};
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec![ARTIFACTS_TOOL_NAME.to_string()],
|
||||
turn.cwd.to_path_buf(),
|
||||
ExecCommandSource::Agent,
|
||||
/*freeform*/ true,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, /*turn_diff_tracker*/ None);
|
||||
let stage = if success {
|
||||
ToolEventStage::Success(exec_output)
|
||||
} else {
|
||||
ToolEventStage::Failure(ToolEventFailure::Output(exec_output))
|
||||
};
|
||||
emitter.emit(ctx, stage).await;
|
||||
}
|
||||
|
||||
fn format_artifact_output(output: &ArtifactCommandOutput) -> String {
|
||||
let stdout = output.stdout.trim();
|
||||
let stderr = output.stderr.trim();
|
||||
let mut sections = vec![format!(
|
||||
"exit_code: {}",
|
||||
output
|
||||
.exit_code
|
||||
.map(|code| code.to_string())
|
||||
.unwrap_or_else(|| "null".to_string())
|
||||
)];
|
||||
if !stdout.is_empty() {
|
||||
sections.push(format!("stdout:\n{stdout}"));
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
sections.push(format!("stderr:\n{stderr}"));
|
||||
}
|
||||
if stdout.is_empty() && stderr.is_empty() && output.success() {
|
||||
sections.push("artifact JS completed successfully.".to_string());
|
||||
}
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
fn error_output(error: &ArtifactsError) -> ArtifactCommandOutput {
|
||||
ArtifactCommandOutput {
|
||||
exit_code: Some(1),
|
||||
stdout: String::new(),
|
||||
stderr: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "artifacts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,98 +0,0 @@
|
||||
use super::*;
|
||||
use crate::packages::versions;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_without_pragma() {
|
||||
let args = parse_freeform_args("console.log('ok');").expect("parse args");
|
||||
assert_eq!(args.source, "console.log('ok');");
|
||||
assert_eq!(args.timeout_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_with_artifact_tool_pragma() {
|
||||
let args = parse_freeform_args("// codex-artifact-tool: timeout_ms=45000\nconsole.log('ok');")
|
||||
.expect("parse args");
|
||||
assert_eq!(args.source, "console.log('ok');");
|
||||
assert_eq!(args.timeout_ms, Some(45_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_json_wrapped_code() {
|
||||
let err = parse_freeform_args("{\"code\":\"console.log('ok')\"}").expect_err("expected error");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("artifacts is a freeform tool and expects raw JavaScript source")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_runtime_manager_uses_openai_codex_release_base() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let manager = default_runtime_manager(codex_home.path().to_path_buf());
|
||||
|
||||
assert_eq!(
|
||||
manager.config().release().base_url().as_str(),
|
||||
"https://github.com/openai/codex/releases/download/"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.config().release().runtime_version(),
|
||||
versions::ARTIFACT_RUNTIME
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cached_runtime_reads_pinned_cache_path() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let platform =
|
||||
codex_artifacts::ArtifactRuntimePlatform::detect_current().expect("detect platform");
|
||||
let install_dir = codex_home
|
||||
.path()
|
||||
.join("packages")
|
||||
.join("artifacts")
|
||||
.join(versions::ARTIFACT_RUNTIME)
|
||||
.join(platform.as_str());
|
||||
std::fs::create_dir_all(&install_dir).expect("create install dir");
|
||||
std::fs::create_dir_all(install_dir.join("dist")).expect("create build entrypoint dir");
|
||||
std::fs::write(
|
||||
install_dir.join("package.json"),
|
||||
serde_json::json!({
|
||||
"name": "@oai/artifact-tool",
|
||||
"version": versions::ARTIFACT_RUNTIME,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/artifact_tool.mjs"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write package json");
|
||||
std::fs::write(
|
||||
install_dir.join("dist/artifact_tool.mjs"),
|
||||
"export const ok = true;\n",
|
||||
)
|
||||
.expect("write build entrypoint");
|
||||
|
||||
let runtime = codex_artifacts::load_cached_runtime(
|
||||
&codex_home
|
||||
.path()
|
||||
.join(codex_artifacts::DEFAULT_CACHE_ROOT_RELATIVE),
|
||||
versions::ARTIFACT_RUNTIME,
|
||||
)
|
||||
.expect("resolve runtime");
|
||||
assert_eq!(runtime.runtime_version(), versions::ARTIFACT_RUNTIME);
|
||||
assert_eq!(
|
||||
runtime.build_js_path(),
|
||||
install_dir.join("dist/artifact_tool.mjs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_artifact_output_includes_success_message_when_silent() {
|
||||
let formatted = format_artifact_output(&ArtifactCommandOutput {
|
||||
exit_code: Some(0),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
});
|
||||
assert!(formatted.contains("artifact JS completed successfully."));
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
pub(crate) mod agent_jobs;
|
||||
pub mod apply_patch;
|
||||
mod artifacts;
|
||||
mod dynamic;
|
||||
mod js_repl;
|
||||
mod list_dir;
|
||||
@@ -35,7 +34,6 @@ use crate::sandboxing::SandboxPermissions;
|
||||
pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler;
|
||||
pub(crate) use crate::tools::code_mode::CodeModeWaitHandler;
|
||||
pub use apply_patch::ApplyPatchHandler;
|
||||
pub use artifacts::ArtifactsHandler;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
pub use dynamic::DynamicToolHandler;
|
||||
@@ -129,18 +127,14 @@ pub(crate) fn normalize_and_validate_additional_permissions(
|
||||
}
|
||||
let Some(additional_permissions) = additional_permissions else {
|
||||
return Err(
|
||||
"missing `additional_permissions`; provide at least one of `network`, `file_system`, or `macos` when using `with_additional_permissions`"
|
||||
"missing `additional_permissions`; provide at least one of `network` or `file_system` when using `with_additional_permissions`"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if additional_permissions.macos.is_some() {
|
||||
return Err("`additional_permissions.macos` is only supported on macOS".to_string());
|
||||
}
|
||||
let normalized = normalize_additional_permissions(additional_permissions)?;
|
||||
if normalized.is_empty() {
|
||||
return Err(
|
||||
"`additional_permissions` must include at least one requested permission in `network`, `file_system`, or `macos`"
|
||||
"`additional_permissions` must include at least one requested permission in `network` or `file_system`"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
//! then optionally layer role-specific config on top.
|
||||
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::agent_resolver::resolve_agent_target;
|
||||
use crate::agent::agent_resolver::resolve_agent_targets;
|
||||
use crate::agent::exceeds_thread_spawn_depth_limit;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
@@ -39,6 +37,27 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(crate) fn parse_agent_id_target(target: &str) -> Result<ThreadId, FunctionCallError> {
|
||||
ThreadId::from_string(target).map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("invalid agent id {target}: {err:?}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_agent_id_targets(
|
||||
targets: Vec<String>,
|
||||
) -> Result<Vec<ThreadId>, FunctionCallError> {
|
||||
if targets.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"agent ids must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
targets
|
||||
.into_iter()
|
||||
.map(|target| parse_agent_id_target(&target))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) use close_agent::Handler as CloseAgentHandler;
|
||||
pub(crate) use resume_agent::Handler as ResumeAgentHandler;
|
||||
pub(crate) use send_input::Handler as SendInputHandler;
|
||||
|
||||
@@ -24,7 +24,7 @@ impl ToolHandler for Handler {
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: CloseAgentArgs = parse_arguments(&arguments)?;
|
||||
let agent_id = resolve_agent_target(&session, &turn, &args.target).await?;
|
||||
let agent_id = parse_agent_id_target(&args.target)?;
|
||||
let receiver_agent = session
|
||||
.services
|
||||
.agent_control
|
||||
|
||||
@@ -24,7 +24,7 @@ impl ToolHandler for Handler {
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: SendInputArgs = parse_arguments(&arguments)?;
|
||||
let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?;
|
||||
let receiver_thread_id = parse_agent_id_target(&args.target)?;
|
||||
let input_items = parse_collab_input(args.message, args.items)?;
|
||||
let prompt = input_preview(&input_items);
|
||||
let receiver_agent = session
|
||||
|
||||
@@ -85,7 +85,7 @@ impl ToolHandler for Handler {
|
||||
&turn.session_source,
|
||||
child_depth,
|
||||
role_name,
|
||||
args.task_name.clone(),
|
||||
/*task_name*/ None,
|
||||
)?),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
|
||||
@@ -111,7 +111,7 @@ impl ToolHandler for Handler {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let (new_agent_path, new_agent_nickname, new_agent_role) =
|
||||
let (_new_agent_path, new_agent_nickname, new_agent_role) =
|
||||
match (&agent_snapshot, new_agent_metadata) {
|
||||
(Some(snapshot), _) => (
|
||||
snapshot.session_source.get_agent_path().map(String::from),
|
||||
@@ -134,7 +134,6 @@ impl ToolHandler for Handler {
|
||||
.and_then(|snapshot| snapshot.reasoning_effort)
|
||||
.unwrap_or(args.reasoning_effort.unwrap_or_default());
|
||||
let nickname = new_agent_nickname.clone();
|
||||
let task_name = new_agent_path.clone();
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
@@ -161,8 +160,7 @@ impl ToolHandler for Handler {
|
||||
);
|
||||
|
||||
Ok(SpawnAgentResult {
|
||||
agent_id: task_name.is_none().then(|| new_thread_id.to_string()),
|
||||
task_name,
|
||||
agent_id: new_thread_id.to_string(),
|
||||
nickname,
|
||||
})
|
||||
}
|
||||
@@ -172,7 +170,6 @@ impl ToolHandler for Handler {
|
||||
struct SpawnAgentArgs {
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
task_name: Option<String>,
|
||||
agent_type: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
@@ -182,8 +179,7 @@ struct SpawnAgentArgs {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct SpawnAgentResult {
|
||||
agent_id: Option<String>,
|
||||
task_name: Option<String>,
|
||||
agent_id: String,
|
||||
nickname: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ impl ToolHandler for Handler {
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: WaitArgs = parse_arguments(&arguments)?;
|
||||
let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?;
|
||||
let receiver_thread_ids = parse_agent_id_targets(args.targets)?;
|
||||
let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len());
|
||||
let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len());
|
||||
for receiver_thread_id in &receiver_thread_ids {
|
||||
|
||||
@@ -281,7 +281,7 @@ pub(crate) fn apply_spawn_agent_runtime_overrides(
|
||||
}
|
||||
|
||||
pub(crate) fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) {
|
||||
if child_depth >= config.agent_max_depth {
|
||||
if child_depth >= config.agent_max_depth && !config.features.enabled(Feature::MultiAgentV2) {
|
||||
let _ = config.features.disable(Feature::SpawnCsv);
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
@@ -294,7 +295,7 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_includes_task_name_key_when_not_named() {
|
||||
async fn spawn_agent_returns_agent_id_without_task_name() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
session.services.agent_control = manager.agent_control();
|
||||
@@ -315,7 +316,7 @@ async fn spawn_agent_includes_task_name_key_when_not_named() {
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
|
||||
assert!(result["agent_id"].is_string());
|
||||
assert_eq!(result["task_name"], serde_json::Value::Null);
|
||||
assert!(result.get("task_name").is_none());
|
||||
assert!(result.get("nickname").is_some());
|
||||
assert_eq!(success, Some(true));
|
||||
}
|
||||
@@ -429,6 +430,81 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let child_path = AgentPath::try_from("/root/worker").expect("agent path");
|
||||
let child_thread_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent_with_metadata(
|
||||
(*turn.config).clone(),
|
||||
vec![UserInput::Text {
|
||||
text: "inspect this repo".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(child_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
crate::agent::control::SpawnAgentOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("worker spawn should succeed")
|
||||
.thread_id;
|
||||
session.conversation_id = child_thread_id;
|
||||
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(child_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
|
||||
SendMessageHandlerV2
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"send_message",
|
||||
function_payload(json!({
|
||||
"target": "/root",
|
||||
"items": [{"type": "text", "text": "done"}]
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("send_message should accept the root agent path");
|
||||
|
||||
assert!(manager.captured_ops().iter().any(|(id, op)| {
|
||||
*id == root.thread_id
|
||||
&& matches!(
|
||||
op,
|
||||
Op::InterAgentCommunication { communication }
|
||||
if communication.author == child_path
|
||||
&& communication.recipient == AgentPath::root()
|
||||
&& communication.other_recipients.is_empty()
|
||||
&& communication.content == "done"
|
||||
&& !communication.trigger_turn
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
@@ -495,11 +571,26 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
|
||||
let result: ListAgentsResult =
|
||||
serde_json::from_str(&content).expect("list_agents result should be json");
|
||||
|
||||
assert_eq!(result.agents.len(), 1);
|
||||
assert_eq!(result.agents[0].agent_name, "/root/worker");
|
||||
assert_eq!(result.agents[0].agent_status, json!({"completed": "done"}));
|
||||
let agent_names = result
|
||||
.agents
|
||||
.iter()
|
||||
.map(|agent| agent.agent_name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(agent_names, vec!["/root", "/root/worker"]);
|
||||
let root_agent = result
|
||||
.agents
|
||||
.iter()
|
||||
.find(|agent| agent.agent_name == "/root")
|
||||
.expect("root agent should be listed");
|
||||
assert_eq!(root_agent.last_task_message.as_deref(), Some("Main thread"));
|
||||
let worker = result
|
||||
.agents
|
||||
.iter()
|
||||
.find(|agent| agent.agent_name == "/root/worker")
|
||||
.expect("worker agent should be listed");
|
||||
assert_eq!(worker.agent_status, json!({"completed": "done"}));
|
||||
assert_eq!(
|
||||
result.agents[0].last_task_message.as_deref(),
|
||||
worker.last_task_message.as_deref(),
|
||||
Some("inspect this repo")
|
||||
);
|
||||
assert_eq!(success, Some(true));
|
||||
@@ -646,7 +737,12 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
|
||||
let result: ListAgentsResult =
|
||||
serde_json::from_str(&content).expect("list_agents result should be json");
|
||||
|
||||
assert!(result.agents.is_empty());
|
||||
assert_eq!(result.agents.len(), 1);
|
||||
assert_eq!(result.agents[0].agent_name, "/root");
|
||||
assert_eq!(
|
||||
result.agents[0].last_task_message.as_deref(),
|
||||
Some("Main thread")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1283,10 +1379,7 @@ async fn send_input_rejects_invalid_id() {
|
||||
let FunctionCallError::RespondToModel(msg) = err else {
|
||||
panic!("expected respond-to-model error");
|
||||
};
|
||||
assert_eq!(
|
||||
msg,
|
||||
"agent_name must use only lowercase letters, digits, and underscores"
|
||||
);
|
||||
assert!(msg.starts_with("invalid agent id not-a-uuid:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1623,7 +1716,7 @@ async fn wait_agent_rejects_invalid_target() {
|
||||
let FunctionCallError::RespondToModel(msg) = err else {
|
||||
panic!("expected respond-to-model error");
|
||||
};
|
||||
assert_eq!(msg, "live agent path `/root/invalid` not found");
|
||||
assert!(msg.starts_with("invalid agent id invalid:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1640,7 +1733,7 @@ async fn wait_agent_rejects_empty_targets() {
|
||||
};
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel("agent targets must be non-empty".to_string())
|
||||
FunctionCallError::RespondToModel("agent ids must be non-empty".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1979,6 +2072,113 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
|
||||
assert_eq!(success, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_close_agent_accepts_task_name_target() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker path should resolve");
|
||||
|
||||
let output = CloseAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"close_agent",
|
||||
function_payload(json!({"target": "worker"})),
|
||||
))
|
||||
.await
|
||||
.expect("close_agent should succeed for v2 task names");
|
||||
let (content, success) = expect_text_output(output);
|
||||
let result: close_agent::CloseAgentResult =
|
||||
serde_json::from_str(&content).expect("close_agent result should be json");
|
||||
assert_ne!(result.previous_status, AgentStatus::NotFound);
|
||||
assert_eq!(success, Some(true));
|
||||
assert_eq!(
|
||||
manager.agent_control().get_status(agent_id).await,
|
||||
AgentStatus::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_close_agent_rejects_root_target_and_id() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let root_path_error = CloseAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"close_agent",
|
||||
function_payload(json!({"target": "/root"})),
|
||||
))
|
||||
.await
|
||||
.expect_err("close_agent should reject the root path");
|
||||
assert_eq!(
|
||||
root_path_error,
|
||||
FunctionCallError::RespondToModel("root is not a spawned agent".to_string())
|
||||
);
|
||||
|
||||
let root_id_error = CloseAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"close_agent",
|
||||
function_payload(json!({"target": root.thread_id.to_string()})),
|
||||
))
|
||||
.await
|
||||
.expect_err("close_agent should reject the root thread id");
|
||||
assert_eq!(
|
||||
root_id_error,
|
||||
FunctionCallError::RespondToModel("root is not a spawned agent".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_agent_submits_shutdown_and_returns_previous_status() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
|
||||
@@ -23,6 +23,8 @@ use codex_protocol::protocol::CollabAgentInteractionEndEvent;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentSpawnEndEvent;
|
||||
use codex_protocol::protocol::CollabCloseBeginEvent;
|
||||
use codex_protocol::protocol::CollabCloseEndEvent;
|
||||
use codex_protocol::protocol::CollabWaitingBeginEvent;
|
||||
use codex_protocol::protocol::CollabWaitingEndEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -31,12 +33,14 @@ use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(crate) use assign_task::Handler as AssignTaskHandler;
|
||||
pub(crate) use close_agent::Handler as CloseAgentHandler;
|
||||
pub(crate) use list_agents::Handler as ListAgentsHandler;
|
||||
pub(crate) use send_message::Handler as SendMessageHandler;
|
||||
pub(crate) use spawn::Handler as SpawnAgentHandler;
|
||||
pub(crate) use wait::Handler as WaitAgentHandler;
|
||||
|
||||
mod assign_task;
|
||||
mod close_agent;
|
||||
mod list_agents;
|
||||
mod message_tool;
|
||||
mod send_message;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = CloseAgentResult;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: CloseAgentArgs = parse_arguments(&arguments)?;
|
||||
let agent_id = resolve_agent_target(&session, &turn, &args.target).await?;
|
||||
let receiver_agent = session
|
||||
.services
|
||||
.agent_control
|
||||
.get_agent_metadata(agent_id)
|
||||
.unwrap_or_default();
|
||||
if receiver_agent
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.is_some_and(AgentPath::is_root)
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"root is not a spawned agent".to_string(),
|
||||
));
|
||||
}
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabCloseBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
receiver_thread_id: agent_id,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let status = match session
|
||||
.services
|
||||
.agent_control
|
||||
.subscribe_status(agent_id)
|
||||
.await
|
||||
{
|
||||
Ok(mut status_rx) => status_rx.borrow_and_update().clone(),
|
||||
Err(err) => {
|
||||
let status = session.services.agent_control.get_status(agent_id).await;
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabCloseEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
receiver_thread_id: agent_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
receiver_agent_role: receiver_agent.agent_role.clone(),
|
||||
status,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
return Err(collab_agent_error(agent_id, err));
|
||||
}
|
||||
};
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.close_agent(agent_id)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(agent_id, err))
|
||||
.map(|_| ());
|
||||
session
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabCloseEndEvent {
|
||||
call_id,
|
||||
sender_thread_id: session.conversation_id,
|
||||
receiver_thread_id: agent_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname,
|
||||
receiver_agent_role: receiver_agent.agent_role,
|
||||
status: status.clone(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
result?;
|
||||
|
||||
Ok(CloseAgentResult {
|
||||
previous_status: status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CloseAgentArgs {
|
||||
target: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub(crate) struct CloseAgentResult {
|
||||
pub(crate) previous_status: AgentStatus,
|
||||
}
|
||||
|
||||
impl ToolOutput for CloseAgentResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "close_agent")
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
tool_output_response_item(call_id, payload, self, Some(true), "close_agent")
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
tool_output_code_mode_result(self, "close_agent")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use async_trait::async_trait;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex::TurnContext;
|
||||
@@ -16,6 +17,7 @@ use crate::protocol::ExecCommandSource;
|
||||
use crate::shell::Shell;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
@@ -23,9 +25,12 @@ use crate::tools::handlers::apply_granted_turn_permissions;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::handlers::implicit_granted_permissions;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use crate::tools::orchestrator::ToolOrchestrator;
|
||||
use crate::tools::registry::PostToolUsePayload;
|
||||
use crate::tools::registry::PreToolUsePayload;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
@@ -48,6 +53,28 @@ pub struct ShellCommandHandler {
|
||||
backend: ShellCommandBackend,
|
||||
}
|
||||
|
||||
fn shell_payload_command(payload: &ToolPayload) -> Option<String> {
|
||||
match payload {
|
||||
ToolPayload::Function { arguments } => parse_arguments::<ShellToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)),
|
||||
ToolPayload::LocalShell { params } => Some(codex_shell_command::parse_command::shlex_join(
|
||||
¶ms.command,
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
|
||||
let ToolPayload::Function { arguments } = payload else {
|
||||
return None;
|
||||
};
|
||||
|
||||
parse_arguments::<ShellCommandToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| params.command)
|
||||
}
|
||||
|
||||
struct RunExecLikeArgs {
|
||||
tool_name: String,
|
||||
exec_params: ExecParams,
|
||||
@@ -178,6 +205,23 @@ impl ToolHandler for ShellHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
|
||||
shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { command })
|
||||
}
|
||||
|
||||
fn post_tool_use_payload(
|
||||
&self,
|
||||
call_id: &str,
|
||||
payload: &ToolPayload,
|
||||
result: &dyn ToolOutput,
|
||||
) -> Option<PostToolUsePayload> {
|
||||
let tool_response = result.post_tool_use_response(call_id, payload)?;
|
||||
Some(PostToolUsePayload {
|
||||
command: shell_payload_command(payload)?,
|
||||
tool_response,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
@@ -268,6 +312,24 @@ impl ToolHandler for ShellCommandHandler {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
|
||||
shell_command_payload_command(&invocation.payload)
|
||||
.map(|command| PreToolUsePayload { command })
|
||||
}
|
||||
|
||||
fn post_tool_use_payload(
|
||||
&self,
|
||||
call_id: &str,
|
||||
payload: &ToolPayload,
|
||||
result: &dyn ToolOutput,
|
||||
) -> Option<PostToolUsePayload> {
|
||||
let tool_response = result.post_tool_use_response(call_id, payload)?;
|
||||
Some(PostToolUsePayload {
|
||||
command: shell_command_payload_command(payload)?,
|
||||
tool_response,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
@@ -493,8 +555,19 @@ impl ShellHandler {
|
||||
&call_id,
|
||||
/*turn_diff_tracker*/ None,
|
||||
);
|
||||
let post_tool_use_response = out
|
||||
.as_ref()
|
||||
.ok()
|
||||
.map(|output| crate::tools::format_exec_output_str(output, turn.truncation_policy))
|
||||
.map(JsonValue::String);
|
||||
let content = emitter.finish(event_ctx, out).await?;
|
||||
Ok(FunctionToolOutput::from_text(content, Some(true)))
|
||||
Ok(FunctionToolOutput {
|
||||
body: vec![
|
||||
codex_protocol::models::FunctionCallOutputContentItem::InputText { text: content },
|
||||
],
|
||||
success: Some(true),
|
||||
post_tool_use_response,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,15 @@ use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::Shell;
|
||||
use crate::shell::ShellType;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// The logic for is_known_safe_command() has heuristics for known shells,
|
||||
@@ -178,3 +186,88 @@ fn shell_command_handler_rejects_login_when_disallowed() {
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_pre_tool_use_payload_uses_joined_command() {
|
||||
let payload = ToolPayload::LocalShell {
|
||||
params: codex_protocol::models::ShellToolCallParams {
|
||||
command: vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"printf hi".to_string(),
|
||||
],
|
||||
workdir: None,
|
||||
timeout_ms: None,
|
||||
sandbox_permissions: None,
|
||||
prefix_rule: None,
|
||||
additional_permissions: None,
|
||||
justification: None,
|
||||
},
|
||||
};
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let handler = ShellHandler;
|
||||
|
||||
assert_eq!(
|
||||
handler.pre_tool_use_payload(&ToolInvocation {
|
||||
session: session.into(),
|
||||
turn: turn.into(),
|
||||
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
|
||||
call_id: "call-41".to_string(),
|
||||
tool_name: "shell".to_string(),
|
||||
tool_namespace: None,
|
||||
payload,
|
||||
}),
|
||||
Some(crate::tools::registry::PreToolUsePayload {
|
||||
command: "bash -lc 'printf hi'".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_command_pre_tool_use_payload_uses_raw_command() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: json!({ "command": "printf shell command" }).to_string(),
|
||||
};
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let handler = ShellCommandHandler {
|
||||
backend: super::ShellCommandBackend::Classic,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
handler.pre_tool_use_payload(&ToolInvocation {
|
||||
session: session.into(),
|
||||
turn: turn.into(),
|
||||
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
|
||||
call_id: "call-42".to_string(),
|
||||
tool_name: "shell_command".to_string(),
|
||||
tool_namespace: None,
|
||||
payload,
|
||||
}),
|
||||
Some(crate::tools::registry::PreToolUsePayload {
|
||||
command: "printf shell command".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_post_tool_use_payload_uses_tool_output_wire_value() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: json!({ "command": "printf shell command" }).to_string(),
|
||||
};
|
||||
let output = FunctionToolOutput {
|
||||
body: vec![],
|
||||
success: Some(true),
|
||||
post_tool_use_response: Some(json!("shell output")),
|
||||
};
|
||||
let handler = ShellCommandHandler {
|
||||
backend: super::ShellCommandBackend::Classic,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
handler.post_tool_use_payload("call-42", &payload, &output),
|
||||
Some(crate::tools::registry::PostToolUsePayload {
|
||||
command: "printf shell command".to_string(),
|
||||
tool_response: json!("shell output"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::shell::Shell;
|
||||
use crate::shell::get_shell_by_model_provided_path;
|
||||
use crate::tools::context::ExecCommandToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::apply_granted_turn_permissions;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
@@ -16,6 +17,8 @@ use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use crate::tools::registry::PostToolUsePayload;
|
||||
use crate::tools::registry::PreToolUsePayload;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::spec::UnifiedExecShellMode;
|
||||
@@ -104,7 +107,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Ok(params) = serde_json::from_str::<ExecCommandArgs>(arguments) else {
|
||||
let Ok(params) = parse_arguments::<ExecCommandArgs>(arguments) else {
|
||||
return true;
|
||||
};
|
||||
let command = match get_command(
|
||||
@@ -119,6 +122,42 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
!is_known_safe_command(&command)
|
||||
}
|
||||
|
||||
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
|
||||
if invocation.tool_name != "exec_command" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ToolPayload::Function { arguments } = &invocation.payload else {
|
||||
return None;
|
||||
};
|
||||
|
||||
parse_arguments::<ExecCommandArgs>(arguments)
|
||||
.ok()
|
||||
.map(|args| PreToolUsePayload { command: args.cmd })
|
||||
}
|
||||
|
||||
fn post_tool_use_payload(
|
||||
&self,
|
||||
call_id: &str,
|
||||
payload: &ToolPayload,
|
||||
result: &dyn ToolOutput,
|
||||
) -> Option<PostToolUsePayload> {
|
||||
let ToolPayload::Function { arguments } = payload else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let args = parse_arguments::<ExecCommandArgs>(arguments).ok()?;
|
||||
if args.tty {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tool_response = result.post_tool_use_response(call_id, payload)?;
|
||||
Some(PostToolUsePayload {
|
||||
command: args.cmd,
|
||||
tool_response,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
|
||||
@@ -11,6 +11,14 @@ use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::tools::context::ExecCommandToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[test]
|
||||
fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> {
|
||||
let json = r#"{"cmd": "echo hello"}"#;
|
||||
@@ -182,3 +190,133 @@ fn exec_command_args_resolve_relative_additional_permissions_against_workdir() -
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_command_pre_tool_use_payload_uses_raw_command() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(),
|
||||
};
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let handler = UnifiedExecHandler;
|
||||
|
||||
assert_eq!(
|
||||
handler.pre_tool_use_payload(&ToolInvocation {
|
||||
session: session.into(),
|
||||
turn: turn.into(),
|
||||
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
|
||||
call_id: "call-43".to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
tool_namespace: None,
|
||||
payload,
|
||||
}),
|
||||
Some(crate::tools::registry::PreToolUsePayload {
|
||||
command: "printf exec command".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_command_pre_tool_use_payload_skips_write_stdin() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "chars": "echo hi" }).to_string(),
|
||||
};
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let handler = UnifiedExecHandler;
|
||||
|
||||
assert_eq!(
|
||||
handler.pre_tool_use_payload(&ToolInvocation {
|
||||
session: session.into(),
|
||||
turn: turn.into(),
|
||||
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
|
||||
call_id: "call-44".to_string(),
|
||||
tool_name: "write_stdin".to_string(),
|
||||
tool_namespace: None,
|
||||
payload,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_commands() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(),
|
||||
};
|
||||
let output = ExecCommandToolOutput {
|
||||
event_call_id: "event-43".to_string(),
|
||||
chunk_id: "chunk-1".to_string(),
|
||||
wall_time: std::time::Duration::from_millis(498),
|
||||
raw_output: b"three".to_vec(),
|
||||
max_output_tokens: None,
|
||||
process_id: None,
|
||||
exit_code: Some(0),
|
||||
original_token_count: None,
|
||||
session_command: Some(vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo three".to_string(),
|
||||
]),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
UnifiedExecHandler.post_tool_use_payload("call-43", &payload, &output),
|
||||
Some(crate::tools::registry::PostToolUsePayload {
|
||||
command: "echo three".to_string(),
|
||||
tool_response: serde_json::json!("three"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_post_tool_use_payload_skips_interactive_exec() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "cmd": "echo three", "tty": true }).to_string(),
|
||||
};
|
||||
let output = ExecCommandToolOutput {
|
||||
event_call_id: "event-44".to_string(),
|
||||
chunk_id: "chunk-1".to_string(),
|
||||
wall_time: std::time::Duration::from_millis(498),
|
||||
raw_output: b"three".to_vec(),
|
||||
max_output_tokens: None,
|
||||
process_id: None,
|
||||
exit_code: Some(0),
|
||||
original_token_count: None,
|
||||
session_command: Some(vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo three".to_string(),
|
||||
]),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
UnifiedExecHandler.post_tool_use_payload("call-44", &payload, &output),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_post_tool_use_payload_skips_running_sessions() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(),
|
||||
};
|
||||
let output = ExecCommandToolOutput {
|
||||
event_call_id: "event-45".to_string(),
|
||||
chunk_id: "chunk-1".to_string(),
|
||||
wall_time: std::time::Duration::from_millis(498),
|
||||
raw_output: b"three".to_vec(),
|
||||
max_output_tokens: None,
|
||||
process_id: Some(45),
|
||||
exit_code: None,
|
||||
original_token_count: None,
|
||||
session_command: Some(vec![
|
||||
"/bin/zsh".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo three".to_string(),
|
||||
]),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
UnifiedExecHandler.post_tool_use_payload("call-45", &payload, &output),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1045,7 +1045,7 @@ impl JsReplManager {
|
||||
has_managed_network_requirements,
|
||||
);
|
||||
let command = SandboxCommand {
|
||||
program: node_path.to_string_lossy().to_string(),
|
||||
program: node_path.into_os_string(),
|
||||
args: vec![
|
||||
"--experimental-vm-modules".to_string(),
|
||||
kernel_path.to_string_lossy().to_string(),
|
||||
@@ -1068,8 +1068,6 @@ impl JsReplManager {
|
||||
enforce_managed_network: has_managed_network_requirements,
|
||||
network: None,
|
||||
sandbox_policy_cwd: &turn.cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
|
||||
use_legacy_landlock: turn.features.use_legacy_landlock(),
|
||||
windows_sandbox_level: turn.windows_sandbox_level,
|
||||
|
||||
@@ -384,7 +384,6 @@ impl NetworkApprovalService {
|
||||
Some(network_approval_context.clone()),
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
/*additional_permissions*/ None,
|
||||
/*skill_metadata*/ None,
|
||||
available_decisions,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -5,10 +5,13 @@ use std::time::Instant;
|
||||
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::hook_runtime::record_additional_contexts;
|
||||
use crate::hook_runtime::run_post_tool_use_hooks;
|
||||
use crate::hook_runtime::run_pre_tool_use_hooks;
|
||||
use crate::memories::usage::emit_metric_for_tool_read;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::sandbox_tags::sandbox_tag;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -21,10 +24,8 @@ use codex_hooks::HookToolInput;
|
||||
use codex_hooks::HookToolInputLocalShell;
|
||||
use codex_hooks::HookToolKind;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -56,6 +57,19 @@ pub trait ToolHandler: Send + Sync {
|
||||
false
|
||||
}
|
||||
|
||||
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
|
||||
None
|
||||
}
|
||||
|
||||
fn post_tool_use_payload(
|
||||
&self,
|
||||
_call_id: &str,
|
||||
_payload: &ToolPayload,
|
||||
_result: &dyn ToolOutput,
|
||||
) -> Option<PostToolUsePayload> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Perform the actual [ToolInvocation] and returns a [ToolOutput] containing
|
||||
/// the final output to return to the model.
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError>;
|
||||
@@ -73,6 +87,7 @@ impl AnyToolResult {
|
||||
call_id,
|
||||
payload,
|
||||
result,
|
||||
..
|
||||
} = self;
|
||||
result.to_response_item(&call_id, &payload)
|
||||
}
|
||||
@@ -85,12 +100,32 @@ impl AnyToolResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PreToolUsePayload {
|
||||
pub(crate) command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct PostToolUsePayload {
|
||||
pub(crate) command: String,
|
||||
pub(crate) tool_response: Value,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
trait AnyToolHandler: Send + Sync {
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool;
|
||||
|
||||
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool;
|
||||
|
||||
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload>;
|
||||
|
||||
fn post_tool_use_payload(
|
||||
&self,
|
||||
call_id: &str,
|
||||
payload: &ToolPayload,
|
||||
result: &dyn ToolOutput,
|
||||
) -> Option<PostToolUsePayload>;
|
||||
|
||||
async fn handle_any(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
@@ -110,6 +145,19 @@ where
|
||||
ToolHandler::is_mutating(self, invocation).await
|
||||
}
|
||||
|
||||
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
|
||||
ToolHandler::pre_tool_use_payload(self, invocation)
|
||||
}
|
||||
|
||||
fn post_tool_use_payload(
|
||||
&self,
|
||||
call_id: &str,
|
||||
payload: &ToolPayload,
|
||||
result: &dyn ToolOutput,
|
||||
) -> Option<PostToolUsePayload> {
|
||||
ToolHandler::post_tool_use_payload(self, call_id, payload, result)
|
||||
}
|
||||
|
||||
async fn handle_any(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
@@ -247,17 +295,18 @@ impl ToolRegistry {
|
||||
return Err(FunctionCallError::Fatal(message));
|
||||
}
|
||||
|
||||
if let Some(command) = pre_tool_use_command(tool_name.as_ref(), &invocation.payload)
|
||||
if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation)
|
||||
&& let Some(reason) = run_pre_tool_use_hooks(
|
||||
&invocation.session,
|
||||
&invocation.turn,
|
||||
invocation.call_id.clone(),
|
||||
command.clone(),
|
||||
pre_tool_use_payload.command.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"Bash command blocked by hook: {reason}. Command: {command}"
|
||||
"Command blocked by PreToolUse hook: {reason}. Command: {}",
|
||||
pre_tool_use_payload.command
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -303,6 +352,33 @@ impl ToolRegistry {
|
||||
Err(err) => (err.to_string(), false),
|
||||
};
|
||||
emit_metric_for_tool_read(&invocation, success).await;
|
||||
let post_tool_use_payload = if success {
|
||||
let guard = response_cell.lock().await;
|
||||
guard.as_ref().and_then(|result| {
|
||||
handler.post_tool_use_payload(
|
||||
&result.call_id,
|
||||
&result.payload,
|
||||
result.result.as_ref(),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let post_tool_use_outcome = if let Some(post_tool_use_payload) = post_tool_use_payload {
|
||||
Some(
|
||||
run_post_tool_use_hooks(
|
||||
&invocation.session,
|
||||
&invocation.turn,
|
||||
invocation.call_id.clone(),
|
||||
post_tool_use_payload.command,
|
||||
post_tool_use_payload.tool_response,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Deprecated: this is the legacy AfterToolUse hook. Prefer the new PostToolUse
|
||||
let hook_abort_error = dispatch_after_tool_use_hook(AfterToolUseHookDispatch {
|
||||
invocation: &invocation,
|
||||
output_preview,
|
||||
@@ -317,6 +393,36 @@ impl ToolRegistry {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Some(outcome) = &post_tool_use_outcome {
|
||||
record_additional_contexts(
|
||||
&invocation.session,
|
||||
&invocation.turn,
|
||||
outcome.additional_contexts.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let replacement_text = if outcome.should_stop {
|
||||
Some(
|
||||
outcome
|
||||
.feedback_message
|
||||
.clone()
|
||||
.or_else(|| outcome.stop_reason.clone())
|
||||
.unwrap_or_else(|| "PostToolUse hook stopped execution".to_string()),
|
||||
)
|
||||
} else {
|
||||
outcome.feedback_message.clone()
|
||||
};
|
||||
if let Some(replacement_text) = replacement_text {
|
||||
let mut guard = response_cell.lock().await;
|
||||
if let Some(result) = guard.as_mut() {
|
||||
result.result = Box::new(FunctionToolOutput::from_text(
|
||||
replacement_text,
|
||||
/*success*/ None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let mut guard = response_cell.lock().await;
|
||||
@@ -431,35 +537,6 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PreToolUseExecCommandArgs {
|
||||
cmd: String,
|
||||
}
|
||||
|
||||
fn pre_tool_use_command(tool_name: &str, payload: &ToolPayload) -> Option<String> {
|
||||
match (tool_name, payload) {
|
||||
("shell" | "container.exec", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<ShellToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command))
|
||||
}
|
||||
("local_shell", ToolPayload::LocalShell { params }) => Some(
|
||||
codex_shell_command::parse_command::shlex_join(¶ms.command),
|
||||
),
|
||||
("shell_command", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<ShellCommandToolCallParams>(arguments)
|
||||
.ok()
|
||||
.map(|params| params.command)
|
||||
}
|
||||
("exec_command", ToolPayload::Function { arguments }) => {
|
||||
serde_json::from_str::<PreToolUseExecCommandArgs>(arguments)
|
||||
.ok()
|
||||
.map(|params| params.cmd)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Hooks use a separate wire-facing input type so hook payload JSON stays stable
|
||||
// and decoupled from core's internal tool runtime representation.
|
||||
impl From<&ToolPayload> for HookToolInput {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use super::*;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
struct TestHandler;
|
||||
@@ -50,63 +47,3 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
|
||||
.is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_uses_raw_shell_command_input() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "command": "printf shell command" }).to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("shell_command", &payload),
|
||||
Some("printf shell command".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_shell_joins_vector_input() {
|
||||
let payload = ToolPayload::LocalShell {
|
||||
params: ShellToolCallParams {
|
||||
command: vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"printf hi".to_string(),
|
||||
],
|
||||
workdir: None,
|
||||
timeout_ms: None,
|
||||
sandbox_permissions: None,
|
||||
prefix_rule: None,
|
||||
additional_permissions: None,
|
||||
justification: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("local_shell", &payload),
|
||||
Some("bash -lc 'printf hi'".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_uses_raw_exec_command_input() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pre_tool_use_command("exec_command", &payload),
|
||||
Some("printf exec command".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_command_skips_non_shell_tools() {
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"plan": [{ "step": "watch the tide", "status": "pending" }]
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(pre_tool_use_command("update_plan", &payload), None);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ pub struct ApplyPatchRequest {
|
||||
pub additional_permissions: Option<PermissionProfile>,
|
||||
pub permissions_preapproved: bool,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub codex_exe: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -66,26 +65,39 @@ impl ApplyPatchRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_sandbox_command(
|
||||
req: &ApplyPatchRequest,
|
||||
_codex_home: &std::path::Path,
|
||||
codex_home: &std::path::Path,
|
||||
) -> Result<SandboxCommand, ToolError> {
|
||||
let exe = if let Some(path) = &req.codex_exe {
|
||||
path.clone()
|
||||
} else {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
codex_windows_sandbox::resolve_current_exe_for_launch(_codex_home, "codex.exe")
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::current_exe().map_err(|e| {
|
||||
ToolError::Rejected(format!("failed to determine codex exe: {e}"))
|
||||
})?
|
||||
}
|
||||
};
|
||||
Ok(SandboxCommand {
|
||||
program: exe.to_string_lossy().to_string(),
|
||||
Ok(Self::build_sandbox_command_with_program(
|
||||
req,
|
||||
codex_windows_sandbox::resolve_current_exe_for_launch(codex_home, "codex.exe"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn build_sandbox_command(
|
||||
req: &ApplyPatchRequest,
|
||||
codex_self_exe: Option<&PathBuf>,
|
||||
) -> Result<SandboxCommand, ToolError> {
|
||||
let exe = Self::resolve_apply_patch_program(codex_self_exe)?;
|
||||
Ok(Self::build_sandbox_command_with_program(req, exe))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_apply_patch_program(codex_self_exe: Option<&PathBuf>) -> Result<PathBuf, ToolError> {
|
||||
if let Some(path) = codex_self_exe {
|
||||
return Ok(path.clone());
|
||||
}
|
||||
|
||||
std::env::current_exe()
|
||||
.map_err(|e| ToolError::Rejected(format!("failed to determine codex exe: {e}")))
|
||||
}
|
||||
|
||||
fn build_sandbox_command_with_program(req: &ApplyPatchRequest, exe: PathBuf) -> SandboxCommand {
|
||||
SandboxCommand {
|
||||
program: exe.into_os_string(),
|
||||
args: vec![
|
||||
CODEX_CORE_APPLY_PATCH_ARG1.to_string(),
|
||||
req.action.patch.clone(),
|
||||
@@ -94,7 +106,7 @@ impl ApplyPatchRuntime {
|
||||
// Run apply_patch with a minimal environment for determinism and to avoid leaks.
|
||||
env: HashMap::new(),
|
||||
additional_permissions: req.additional_permissions.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn stdout_stream(ctx: &ToolCtx) -> Option<crate::exec::StdoutStream> {
|
||||
@@ -134,13 +146,13 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
|
||||
let approval_keys = self.approval_keys(req);
|
||||
let changes = req.changes.clone();
|
||||
Box::pin(async move {
|
||||
if req.permissions_preapproved && retry_reason.is_none() {
|
||||
return ReviewDecision::Approved;
|
||||
}
|
||||
if routes_approval_to_guardian(turn) {
|
||||
let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id);
|
||||
return review_approval_request(session, turn, action, retry_reason).await;
|
||||
}
|
||||
if req.permissions_preapproved && retry_reason.is_none() {
|
||||
return ReviewDecision::Approved;
|
||||
}
|
||||
if let Some(reason) = retry_reason {
|
||||
let rx_approve = session
|
||||
.request_patch_approval(
|
||||
@@ -200,7 +212,10 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let command = Self::build_sandbox_command(req, &ctx.turn.config.codex_home)?;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let command = Self::build_sandbox_command(req, ctx.turn.codex_self_exe.as_ref())?;
|
||||
let options = ExecOptions {
|
||||
expiration: req.timeout_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
|
||||
@@ -2,6 +2,8 @@ use super::*;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn wants_no_sandbox_approval_granular_respects_sandbox_flag() {
|
||||
@@ -51,7 +53,6 @@ fn guardian_review_request_includes_patch_context() {
|
||||
additional_permissions: None,
|
||||
permissions_preapproved: false,
|
||||
timeout_ms: None,
|
||||
codex_exe: None,
|
||||
};
|
||||
|
||||
let guardian_request = ApplyPatchRuntime::build_guardian_review_request(&request, "call-1");
|
||||
@@ -67,3 +68,71 @@ fn guardian_review_request_includes_patch_context() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn build_sandbox_command_prefers_configured_codex_self_exe_for_apply_patch() {
|
||||
let path = std::env::temp_dir().join("apply-patch-current-exe-test.txt");
|
||||
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
|
||||
let request = ApplyPatchRequest {
|
||||
action,
|
||||
file_paths: vec![
|
||||
AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"),
|
||||
],
|
||||
changes: HashMap::from([(
|
||||
path,
|
||||
FileChange::Add {
|
||||
content: "hello".to_string(),
|
||||
},
|
||||
)]),
|
||||
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
additional_permissions: None,
|
||||
permissions_preapproved: false,
|
||||
timeout_ms: None,
|
||||
};
|
||||
let codex_self_exe = PathBuf::from("/tmp/codex");
|
||||
|
||||
let command = ApplyPatchRuntime::build_sandbox_command(&request, Some(&codex_self_exe))
|
||||
.expect("build sandbox command");
|
||||
|
||||
assert_eq!(command.program, codex_self_exe.into_os_string());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn build_sandbox_command_falls_back_to_current_exe_for_apply_patch() {
|
||||
let path = std::env::temp_dir().join("apply-patch-current-exe-test.txt");
|
||||
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
|
||||
let request = ApplyPatchRequest {
|
||||
action,
|
||||
file_paths: vec![
|
||||
AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"),
|
||||
],
|
||||
changes: HashMap::from([(
|
||||
path,
|
||||
FileChange::Add {
|
||||
content: "hello".to_string(),
|
||||
},
|
||||
)]),
|
||||
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
additional_permissions: None,
|
||||
permissions_preapproved: false,
|
||||
timeout_ms: None,
|
||||
};
|
||||
|
||||
let command =
|
||||
ApplyPatchRuntime::build_sandbox_command(&request, None).expect("build sandbox command");
|
||||
|
||||
assert_eq!(
|
||||
command.program,
|
||||
std::env::current_exe()
|
||||
.expect("current exe")
|
||||
.into_os_string()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ Module: runtimes
|
||||
Concrete ToolRuntime implementations for specific tools. Each runtime stays
|
||||
small and focused and reuses the orchestrator for approvals + sandbox + retry.
|
||||
*/
|
||||
use crate::SkillMetadata;
|
||||
use crate::path_utils;
|
||||
use crate::shell::Shell;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
@@ -17,14 +16,6 @@ pub mod apply_patch;
|
||||
pub mod shell;
|
||||
pub mod unified_exec;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ExecveSessionApproval {
|
||||
/// If this execve session approval is associated with a skill script, this
|
||||
/// field contains metadata about the skill.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub skill: Option<SkillMetadata>,
|
||||
}
|
||||
|
||||
/// Shared helper to construct sandbox transform inputs from a tokenized command line.
|
||||
/// Validates that at least a program is present.
|
||||
pub(crate) fn build_sandbox_command(
|
||||
@@ -37,7 +28,7 @@ pub(crate) fn build_sandbox_command(
|
||||
.split_first()
|
||||
.ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?;
|
||||
Ok(SandboxCommand {
|
||||
program: program.clone(),
|
||||
program: program.clone().into(),
|
||||
args: args.to_vec(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: env.clone(),
|
||||
|
||||
@@ -184,7 +184,6 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
req.additional_permissions.clone(),
|
||||
/*skill_metadata*/ None,
|
||||
available_decisions,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::ShellRequest;
|
||||
use crate::SkillMetadata;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
@@ -13,8 +12,6 @@ use crate::sandboxing::ExecOptions;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
use crate::skills_load_input_from_config;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::runtimes::build_sandbox_command;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
@@ -26,12 +23,10 @@ use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ExecApprovalRequestSkillMetadata;
|
||||
use codex_protocol::protocol::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -74,9 +69,6 @@ const REJECT_SANDBOX_APPROVAL_REASON: &str =
|
||||
"approval required by policy, but AskForApproval::Granular.sandbox_approval is false";
|
||||
const REJECT_RULES_APPROVAL_REASON: &str =
|
||||
"approval required by policy rule, but AskForApproval::Granular.rules is false";
|
||||
const REJECT_SKILL_APPROVAL_REASON: &str =
|
||||
"approval required by skill, but AskForApproval::Granular.skill_approval is false";
|
||||
|
||||
fn approval_sandbox_permissions(
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions_preapproved: bool,
|
||||
@@ -161,12 +153,6 @@ pub(super) async fn try_run_zsh_fork(
|
||||
windows_sandbox_level,
|
||||
arg0,
|
||||
sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: ctx
|
||||
.turn
|
||||
.config
|
||||
.permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
|
||||
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
|
||||
};
|
||||
@@ -265,12 +251,6 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
windows_sandbox_level: exec_request.windows_sandbox_level,
|
||||
arg0: exec_request.arg0.clone(),
|
||||
sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: ctx
|
||||
.turn
|
||||
.config
|
||||
.permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
|
||||
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
|
||||
};
|
||||
@@ -327,9 +307,6 @@ struct CoreShellActionProvider {
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum DecisionSource {
|
||||
SkillScript {
|
||||
skill: SkillMetadata,
|
||||
},
|
||||
PrefixRule,
|
||||
/// Often, this is `is_safe_command()`.
|
||||
UnmatchedCommandFallback,
|
||||
@@ -341,11 +318,6 @@ fn execve_prompt_is_rejected_by_policy(
|
||||
) -> Option<&'static str> {
|
||||
match (approval_policy, decision_source) {
|
||||
(AskForApproval::Never, _) => Some(PROMPT_CONFLICT_REASON),
|
||||
(AskForApproval::Granular(granular_config), DecisionSource::SkillScript { .. })
|
||||
if !granular_config.allows_skill_approval() =>
|
||||
{
|
||||
Some(REJECT_SKILL_APPROVAL_REASON)
|
||||
}
|
||||
(AskForApproval::Granular(granular_config), DecisionSource::PrefixRule)
|
||||
if !granular_config.allows_rules_approval() =>
|
||||
{
|
||||
@@ -374,7 +346,6 @@ impl CoreShellActionProvider {
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> EscalationExecution {
|
||||
match sandbox_permissions {
|
||||
SandboxPermissions::UseDefault => EscalationExecution::TurnDefault,
|
||||
@@ -388,8 +359,6 @@ impl CoreShellActionProvider {
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: file_system_sandbox_policy.clone(),
|
||||
network_sandbox_policy,
|
||||
macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions
|
||||
.cloned(),
|
||||
},
|
||||
))
|
||||
})
|
||||
@@ -397,17 +366,6 @@ impl CoreShellActionProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_escalation_execution(skill: &SkillMetadata) -> EscalationExecution {
|
||||
let permission_profile = skill.permission_profile.clone().unwrap_or_default();
|
||||
if permission_profile.is_empty() {
|
||||
EscalationExecution::TurnDefault
|
||||
} else {
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn prompt(
|
||||
&self,
|
||||
program: &AbsolutePathBuf,
|
||||
@@ -415,7 +373,6 @@ impl CoreShellActionProvider {
|
||||
workdir: &AbsolutePathBuf,
|
||||
stopwatch: &Stopwatch,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
decision_source: &DecisionSource,
|
||||
) -> anyhow::Result<ReviewDecision> {
|
||||
let command = join_program_and_argv(program, argv);
|
||||
let workdir = workdir.to_path_buf();
|
||||
@@ -442,28 +399,6 @@ impl CoreShellActionProvider {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let available_decisions = vec![
|
||||
Some(ReviewDecision::Approved),
|
||||
// Currently, ApprovedForSession is only honored for skills,
|
||||
// so only offer it for skill script approvals.
|
||||
if matches!(decision_source, DecisionSource::SkillScript { .. }) {
|
||||
Some(ReviewDecision::ApprovedForSession)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Some(ReviewDecision::Abort),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
let skill_metadata = match decision_source {
|
||||
DecisionSource::SkillScript { skill } => {
|
||||
Some(ExecApprovalRequestSkillMetadata {
|
||||
path_to_skills_md: skill.path_to_skills_md.clone(),
|
||||
})
|
||||
}
|
||||
DecisionSource::PrefixRule | DecisionSource::UnmatchedCommandFallback => None,
|
||||
};
|
||||
session
|
||||
.request_command_approval(
|
||||
&turn,
|
||||
@@ -475,48 +410,13 @@ impl CoreShellActionProvider {
|
||||
/*network_approval_context*/ None,
|
||||
/*proposed_execpolicy_amendment*/ None,
|
||||
additional_permissions,
|
||||
skill_metadata,
|
||||
Some(available_decisions),
|
||||
Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Because we should be intercepting execve(2) calls, `program` should be
|
||||
/// an absolute path. The idea is that we check to see whether it matches
|
||||
/// any skills.
|
||||
async fn find_skill(&self, program: &AbsolutePathBuf) -> Option<SkillMetadata> {
|
||||
let force_reload = false;
|
||||
let turn_config = self.turn.config.as_ref();
|
||||
let plugin_outcome = self
|
||||
.session
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(turn_config, effective_skill_roots);
|
||||
let skills_outcome = self
|
||||
.session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&skills_input, force_reload)
|
||||
.await;
|
||||
|
||||
let program_path = program.as_path();
|
||||
for skill in skills_outcome.skills {
|
||||
// We intentionally ignore "enabled" status here for now.
|
||||
let Some(skill_root) = skill.path_to_skills_md.parent() else {
|
||||
continue;
|
||||
};
|
||||
if program_path.starts_with(skill_root.join("scripts")) {
|
||||
return Some(skill);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn process_decision(
|
||||
&self,
|
||||
@@ -540,17 +440,11 @@ impl CoreShellActionProvider {
|
||||
EscalationDecision::deny(Some("Execution forbidden by policy".to_string()))
|
||||
} else {
|
||||
match self
|
||||
.prompt(
|
||||
program,
|
||||
argv,
|
||||
workdir,
|
||||
&self.stopwatch,
|
||||
prompt_permissions,
|
||||
&decision_source,
|
||||
)
|
||||
.prompt(program, argv, workdir, &self.stopwatch, prompt_permissions)
|
||||
.await?
|
||||
{
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedForSession
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
|
||||
if needs_escalation {
|
||||
EscalationDecision::escalate(escalation_execution.clone())
|
||||
@@ -558,33 +452,6 @@ impl CoreShellActionProvider {
|
||||
EscalationDecision::run()
|
||||
}
|
||||
}
|
||||
ReviewDecision::ApprovedForSession => {
|
||||
// Currently, we only add session approvals for
|
||||
// skill scripts because we are storing only the
|
||||
// `program` whereas prefix rules may be restricted by a longer prefix.
|
||||
if let DecisionSource::SkillScript { skill } = decision_source {
|
||||
tracing::debug!(
|
||||
"Adding session approval for {program:?} due to user approval of skill script {skill:?}"
|
||||
);
|
||||
self.session
|
||||
.services
|
||||
.execve_session_approvals
|
||||
.write()
|
||||
.await
|
||||
.insert(
|
||||
program.clone(),
|
||||
ExecveSessionApproval {
|
||||
skill: Some(skill.clone()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if needs_escalation {
|
||||
EscalationDecision::escalate(escalation_execution.clone())
|
||||
} else {
|
||||
EscalationDecision::run()
|
||||
}
|
||||
}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
@@ -641,69 +508,6 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
"Determining escalation action for command {program:?} with args {argv:?} in {workdir:?}"
|
||||
);
|
||||
|
||||
// Check to see whether `program` has an existing entry in
|
||||
// `execve_session_approvals`. If so, we can skip policy checks and user
|
||||
// prompts and go straight to allowing execution.
|
||||
let approval = {
|
||||
self.session
|
||||
.services
|
||||
.execve_session_approvals
|
||||
.read()
|
||||
.await
|
||||
.get(program)
|
||||
.cloned()
|
||||
};
|
||||
if let Some(approval) = approval {
|
||||
tracing::debug!(
|
||||
"Found session approval for {program:?}, allowing execution without further checks"
|
||||
);
|
||||
let execution = approval
|
||||
.skill
|
||||
.as_ref()
|
||||
.map(Self::skill_escalation_execution)
|
||||
.unwrap_or(EscalationExecution::TurnDefault);
|
||||
|
||||
return Ok(EscalationDecision::escalate(execution));
|
||||
}
|
||||
|
||||
// In the usual case, the execve wrapper reports the command being
|
||||
// executed in `program`, so a direct skill lookup is sufficient.
|
||||
if let Some(skill) = self.find_skill(program).await {
|
||||
// For now, scripts that look like they belong to skills bypass
|
||||
// general exec policy evaluation. Permissionless skills inherit the
|
||||
// turn sandbox directly; skills with declared permissions still
|
||||
// prompt here before applying their permission profile.
|
||||
let prompt_permissions = skill.permission_profile.clone();
|
||||
if prompt_permissions
|
||||
.as_ref()
|
||||
.is_none_or(PermissionProfile::is_empty)
|
||||
{
|
||||
tracing::debug!(
|
||||
"Matched {program:?} to permissionless skill {skill:?}, inheriting turn sandbox"
|
||||
);
|
||||
return Ok(EscalationDecision::escalate(
|
||||
EscalationExecution::TurnDefault,
|
||||
));
|
||||
}
|
||||
tracing::debug!("Matched {program:?} to skill {skill:?}, prompting for approval");
|
||||
let needs_escalation = true;
|
||||
let decision_source = DecisionSource::SkillScript {
|
||||
skill: skill.clone(),
|
||||
};
|
||||
return self
|
||||
.process_decision(
|
||||
Decision::Prompt,
|
||||
needs_escalation,
|
||||
program,
|
||||
argv,
|
||||
workdir,
|
||||
prompt_permissions,
|
||||
Self::skill_escalation_execution(&skill),
|
||||
decision_source,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let evaluation = {
|
||||
let policy = self.policy.read().await;
|
||||
evaluate_intercepted_exec_policy(
|
||||
@@ -740,13 +544,7 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
&self.file_system_sandbox_policy,
|
||||
self.network_sandbox_policy,
|
||||
self.prompt_permissions.as_ref(),
|
||||
self.turn
|
||||
.config
|
||||
.permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
),
|
||||
DecisionSource::SkillScript { .. } => unreachable!("handled above"),
|
||||
};
|
||||
self.process_decision(
|
||||
evaluation.decision,
|
||||
@@ -868,8 +666,6 @@ struct CoreShellCommandExecutor {
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
arg0: Option<String>,
|
||||
sandbox_policy_cwd: PathBuf,
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
use_legacy_landlock: bool,
|
||||
}
|
||||
@@ -882,8 +678,6 @@ struct PrepareSandboxedExecParams<'a> {
|
||||
file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -968,17 +762,12 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
file_system_sandbox_policy: &self.file_system_sandbox_policy,
|
||||
network_sandbox_policy: self.network_sandbox_policy,
|
||||
additional_permissions: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: self
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
})?
|
||||
}
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
)) => {
|
||||
// Merge additive permissions into the existing turn/request sandbox policy.
|
||||
// On macOS, additional profile extensions are unioned with the turn defaults.
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
@@ -987,10 +776,6 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
file_system_sandbox_policy: &self.file_system_sandbox_policy,
|
||||
network_sandbox_policy: self.network_sandbox_policy,
|
||||
additional_permissions: Some(permission_profile),
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: self
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
})?
|
||||
}
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
|
||||
@@ -1003,10 +788,6 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
file_system_sandbox_policy: &permissions.file_system_sandbox_policy,
|
||||
network_sandbox_policy: permissions.network_sandbox_policy,
|
||||
additional_permissions: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
})?
|
||||
}
|
||||
};
|
||||
@@ -1029,8 +810,6 @@ impl CoreShellCommandExecutor {
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
additional_permissions,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
} = params;
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
@@ -1044,7 +823,7 @@ impl CoreShellCommandExecutor {
|
||||
self.network.is_some(),
|
||||
);
|
||||
let command = SandboxCommand {
|
||||
program: program.clone(),
|
||||
program: program.clone().into(),
|
||||
args: args.to_vec(),
|
||||
cwd: workdir.to_path_buf(),
|
||||
env,
|
||||
@@ -1063,8 +842,6 @@ impl CoreShellCommandExecutor {
|
||||
enforce_managed_network: self.network.is_some(),
|
||||
network: self.network.as_ref(),
|
||||
sandbox_policy_cwd: &self.sandbox_policy_cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(),
|
||||
use_legacy_landlock: self.use_legacy_landlock,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::CoreShellActionProvider;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::CoreShellCommandExecutor;
|
||||
use super::InterceptedExecPolicyContext;
|
||||
use super::ParsedShellCommand;
|
||||
use super::commands_for_intercepted_exec_policy;
|
||||
@@ -8,13 +6,6 @@ use super::evaluate_intercepted_exec_policy;
|
||||
use super::extract_shell_script;
|
||||
use super::join_program_and_argv;
|
||||
use super::map_exec_result;
|
||||
use crate::SkillMetadata;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::Constrained;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::Permissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::GranularApprovalConfig;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
@@ -24,11 +15,7 @@ use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
@@ -36,20 +23,13 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_sandboxing::SandboxType;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use codex_shell_escalation::EscalationExecution;
|
||||
use codex_shell_escalation::EscalationPermissions;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
use codex_shell_escalation::Permissions as EscalatedPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -78,60 +58,6 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
|
||||
}])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
|
||||
FileSystemSandboxPolicy::unrestricted()
|
||||
}
|
||||
|
||||
fn test_skill_metadata(permission_profile: Option<PermissionProfile>) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: "skill".to_string(),
|
||||
description: "description".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permission_profile,
|
||||
managed_network_override: None,
|
||||
path_to_skills_md: PathBuf::from("/tmp/skill/SKILL.md"),
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execve_prompt_rejection_uses_skill_approval_for_skill_scripts() {
|
||||
let decision_source = super::DecisionSource::SkillScript {
|
||||
skill: test_skill_metadata(None),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
super::execve_prompt_is_rejected_by_policy(
|
||||
AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
&decision_source,
|
||||
),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
super::execve_prompt_is_rejected_by_policy(
|
||||
AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: false,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
&decision_source,
|
||||
),
|
||||
Some("approval required by skill, but AskForApproval::Granular.skill_approval is false"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execve_prompt_rejection_keeps_prefix_rules_on_rules_flag() {
|
||||
assert_eq!(
|
||||
@@ -345,10 +271,6 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
},
|
||||
]);
|
||||
let network_sandbox_policy = NetworkSandboxPolicy::Restricted;
|
||||
let macos_seatbelt_profile_extensions = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::shell_request_escalation_execution(
|
||||
@@ -357,7 +279,6 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
None,
|
||||
Some(&macos_seatbelt_profile_extensions),
|
||||
),
|
||||
EscalationExecution::TurnDefault,
|
||||
);
|
||||
@@ -368,7 +289,6 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
None,
|
||||
Some(&macos_seatbelt_profile_extensions),
|
||||
),
|
||||
EscalationExecution::Unsandboxed,
|
||||
);
|
||||
@@ -379,55 +299,17 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
Some(&requested_permissions),
|
||||
Some(&macos_seatbelt_profile_extensions),
|
||||
),
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(
|
||||
EscalatedPermissions {
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
macos_seatbelt_profile_extensions: Some(macos_seatbelt_profile_extensions),
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_escalation_execution_uses_additional_permissions() {
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(Some(
|
||||
requested_permissions.clone(),
|
||||
))),
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
requested_permissions,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_escalation_execution_ignores_empty_permissions() {
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(Some(
|
||||
PermissionProfile::default(),
|
||||
))),
|
||||
EscalationExecution::TurnDefault,
|
||||
);
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::skill_escalation_execution(&test_skill_metadata(None)),
|
||||
EscalationExecution::TurnDefault,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled() {
|
||||
let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#;
|
||||
@@ -645,191 +527,3 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"])
|
||||
evaluation.decision
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
sandbox: SandboxType::None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
arg0: None,
|
||||
sandbox_policy_cwd: cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
..Default::default()
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
.prepare_escalated_exec(
|
||||
&AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(),
|
||||
&["echo".to_string(), "ok".to_string()],
|
||||
&cwd,
|
||||
HashMap::new(),
|
||||
EscalationExecution::TurnDefault,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.command.first().map(String::as_str),
|
||||
Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
);
|
||||
assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p"));
|
||||
assert!(
|
||||
prepared
|
||||
.command
|
||||
.get(2)
|
||||
.is_some_and(|policy| policy.contains("(allow user-preference-write)")),
|
||||
"expected seatbelt policy to include macOS extension profile: {:?}",
|
||||
prepared.command
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
sandbox: SandboxType::None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Enabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
arg0: None,
|
||||
sandbox_policy_cwd: cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
};
|
||||
|
||||
let permissions = Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
windows_sandbox_private_desktop: false,
|
||||
macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
.prepare_escalated_exec(
|
||||
&AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(),
|
||||
&["echo".to_string(), "ok".to_string()],
|
||||
&cwd,
|
||||
HashMap::new(),
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(
|
||||
EscalatedPermissions {
|
||||
sandbox_policy: permissions.sandbox_policy.get().clone(),
|
||||
file_system_sandbox_policy: permissions.file_system_sandbox_policy.clone(),
|
||||
network_sandbox_policy: permissions.network_sandbox_policy,
|
||||
macos_seatbelt_profile_extensions: permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.command.first().map(String::as_str),
|
||||
Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
);
|
||||
assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p"));
|
||||
assert!(
|
||||
prepared
|
||||
.command
|
||||
.get(2)
|
||||
.is_some_and(|policy| policy.contains("(allow user-preference-write)")),
|
||||
"expected seatbelt policy to include macOS extension profile: {:?}",
|
||||
prepared.command
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_macos_extensions() {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
sandbox: SandboxType::None,
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
arg0: None,
|
||||
sandbox_policy_cwd: cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadOnly,
|
||||
..Default::default()
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
.prepare_escalated_exec(
|
||||
&AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(),
|
||||
&["echo".to_string(), "ok".to_string()],
|
||||
&cwd,
|
||||
HashMap::new(),
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
PermissionProfile {
|
||||
macos: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_calendar: true,
|
||||
macos_reminders: false,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let policy = prepared
|
||||
.command
|
||||
.get(2)
|
||||
.expect("seatbelt policy should be present");
|
||||
assert_eq!(
|
||||
prepared.command.first().map(String::as_str),
|
||||
Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
);
|
||||
assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p"));
|
||||
assert!(
|
||||
policy.contains("(allow user-preference-read)"),
|
||||
"expected turn macOS seatbelt extensions to be preserved: {:?}",
|
||||
prepared.command
|
||||
);
|
||||
assert!(
|
||||
policy.contains("(allow mach-lookup (global-name \"com.apple.CalendarAgent\"))"),
|
||||
"expected requested macOS seatbelt extensions to be included: {:?}",
|
||||
prepared.command
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,9 +45,12 @@ use futures::future::BoxFuture;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Request payload used by the unified-exec runtime after approvals and
|
||||
/// sandbox preferences have been resolved for the current turn.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UnifiedExecRequest {
|
||||
pub command: Vec<String>,
|
||||
pub process_id: i32,
|
||||
pub cwd: PathBuf,
|
||||
pub env: HashMap<String, String>,
|
||||
pub explicit_env_overrides: HashMap<String, String>,
|
||||
@@ -61,6 +64,8 @@ pub struct UnifiedExecRequest {
|
||||
pub exec_approval_requirement: ExecApprovalRequirement,
|
||||
}
|
||||
|
||||
/// Cache key for approval decisions that can be reused across equivalent
|
||||
/// unified-exec launches.
|
||||
#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct UnifiedExecApprovalKey {
|
||||
pub command: Vec<String>,
|
||||
@@ -70,12 +75,15 @@ pub struct UnifiedExecApprovalKey {
|
||||
pub additional_permissions: Option<PermissionProfile>,
|
||||
}
|
||||
|
||||
/// Runtime adapter that keeps policy and sandbox orchestration on the
|
||||
/// unified-exec side while delegating process startup to the manager.
|
||||
pub struct UnifiedExecRuntime<'a> {
|
||||
manager: &'a UnifiedExecProcessManager,
|
||||
shell_mode: UnifiedExecShellMode,
|
||||
}
|
||||
|
||||
impl<'a> UnifiedExecRuntime<'a> {
|
||||
/// Creates a runtime bound to the shared unified-exec process manager.
|
||||
pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self {
|
||||
Self {
|
||||
manager,
|
||||
@@ -153,7 +161,6 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
req.additional_permissions.clone(),
|
||||
/*skill_metadata*/ None,
|
||||
available_decisions,
|
||||
)
|
||||
.await
|
||||
@@ -232,12 +239,19 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
.await?
|
||||
{
|
||||
Some(prepared) => {
|
||||
if ctx.turn.environment.exec_server_url().is_some() {
|
||||
return Err(ToolError::Rejected(
|
||||
"unified_exec zsh-fork is not supported when exec_server_url is configured".to_string(),
|
||||
));
|
||||
}
|
||||
return self
|
||||
.manager
|
||||
.open_session_with_exec_env(
|
||||
req.process_id,
|
||||
&prepared.exec_request,
|
||||
req.tty,
|
||||
prepared.spawn_lifecycle,
|
||||
ctx.turn.environment.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
@@ -268,7 +282,13 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
.env_for(command, options, req.network.as_ref())
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
self.manager
|
||||
.open_session_with_exec_env(&exec_env, req.tty, Box::new(NoopSpawnLifecycle))
|
||||
.open_session_with_exec_env(
|
||||
req.process_id,
|
||||
&exec_env,
|
||||
req.tty,
|
||||
Box::new(NoopSpawnLifecycle),
|
||||
ctx.turn.environment.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
UnifiedExecError::SandboxDenied { output, .. } => {
|
||||
|
||||
@@ -348,8 +348,6 @@ impl<'a> SandboxAttempt<'a> {
|
||||
enforce_managed_network: self.enforce_managed_network,
|
||||
network,
|
||||
sandbox_policy_cwd: self.sandbox_cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe,
|
||||
use_legacy_landlock: self.use_legacy_landlock,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
|
||||
+201
-359
@@ -46,6 +46,8 @@ use codex_protocol::openai_models::WebSearchToolType;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_tools::parse_mcp_tool;
|
||||
pub use codex_tools::parse_tool_input_schema;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -55,6 +57,11 @@ use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub type JsonSchema = codex_tools::JsonSchema;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use codex_tools::mcp_call_tool_result_output_schema;
|
||||
|
||||
const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_description.md");
|
||||
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str =
|
||||
@@ -126,12 +133,25 @@ fn agent_status_output_schema() -> JsonValue {
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_agent_output_schema(multi_agent_v2: bool) -> JsonValue {
|
||||
let task_name_description = if multi_agent_v2 {
|
||||
"Canonical task name for the spawned agent."
|
||||
} else {
|
||||
"Canonical task name for the spawned agent when one was assigned."
|
||||
};
|
||||
fn spawn_agent_output_schema_v1() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "Thread identifier for the spawned agent."
|
||||
},
|
||||
"nickname": {
|
||||
"type": ["string", "null"],
|
||||
"description": "User-facing nickname for the spawned agent when available."
|
||||
}
|
||||
},
|
||||
"required": ["agent_id", "nickname"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_agent_output_schema_v2() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -141,7 +161,7 @@ fn spawn_agent_output_schema(multi_agent_v2: bool) -> JsonValue {
|
||||
},
|
||||
"task_name": {
|
||||
"type": ["string", "null"],
|
||||
"description": task_name_description
|
||||
"description": "Canonical task name for the spawned agent."
|
||||
},
|
||||
"nickname": {
|
||||
"type": ["string", "null"],
|
||||
@@ -217,7 +237,7 @@ fn wait_output_schema_v1() -> JsonValue {
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "object",
|
||||
"description": "Final statuses keyed by canonical task name when available, otherwise by agent id.",
|
||||
"description": "Final statuses keyed by agent id.",
|
||||
"additionalProperties": agent_status_output_schema()
|
||||
},
|
||||
"timed_out": {
|
||||
@@ -334,7 +354,6 @@ pub(crate) struct ToolsConfig {
|
||||
pub can_request_original_image_detail: bool,
|
||||
pub collab_tools: bool,
|
||||
pub multi_agent_v2: bool,
|
||||
pub artifact_tools: bool,
|
||||
pub request_user_input: bool,
|
||||
pub default_mode_request_user_input: bool,
|
||||
pub experimental_supported_tools: Vec<String>,
|
||||
@@ -394,8 +413,6 @@ impl ToolsConfig {
|
||||
&& features.enabled(Feature::Apps)
|
||||
&& features.enabled(Feature::Plugins);
|
||||
let include_original_image_detail = can_request_original_image_detail(features, model_info);
|
||||
let include_artifact_tools =
|
||||
features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime();
|
||||
let include_image_gen_tool =
|
||||
features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info);
|
||||
let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals);
|
||||
@@ -471,7 +488,6 @@ impl ToolsConfig {
|
||||
can_request_original_image_detail: include_original_image_detail,
|
||||
collab_tools: include_collab_tools,
|
||||
multi_agent_v2: include_multi_agent_v2,
|
||||
artifact_tools: include_artifact_tools,
|
||||
request_user_input: include_request_user_input,
|
||||
default_mode_request_user_input: include_default_mode_request_user_input,
|
||||
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
|
||||
@@ -530,62 +546,6 @@ fn supports_image_generation(model_info: &ModelInfo) -> bool {
|
||||
model_info.input_modalities.contains(&InputModality::Image)
|
||||
}
|
||||
|
||||
/// Generic JSON‑Schema subset needed for our tool definitions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum JsonSchema {
|
||||
Boolean {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
},
|
||||
String {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
},
|
||||
/// MCP schema allows "number" | "integer" for Number
|
||||
#[serde(alias = "integer")]
|
||||
Number {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
},
|
||||
Array {
|
||||
items: Box<JsonSchema>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
},
|
||||
Object {
|
||||
properties: BTreeMap<String, JsonSchema>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
required: Option<Vec<String>>,
|
||||
#[serde(
|
||||
rename = "additionalProperties",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
additional_properties: Option<AdditionalProperties>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether additional properties are allowed, and if so, any required schema
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum AdditionalProperties {
|
||||
Boolean(bool),
|
||||
Schema(Box<JsonSchema>),
|
||||
}
|
||||
|
||||
impl From<bool> for AdditionalProperties {
|
||||
fn from(b: bool) -> Self {
|
||||
Self::Boolean(b)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsonSchema> for AdditionalProperties {
|
||||
fn from(s: JsonSchema) -> Self {
|
||||
Self::Schema(Box::new(s))
|
||||
}
|
||||
}
|
||||
|
||||
fn create_network_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::from([(
|
||||
@@ -1137,10 +1097,8 @@ fn create_collab_input_items_schema() -> JsonSchema {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
let available_models_description = spawn_agent_models_description(&config.available_models);
|
||||
let return_value_description = "Returns the canonical task name when the spawned agent was named, otherwise the agent id, plus the user-facing nickname when available.";
|
||||
let mut properties = BTreeMap::from([
|
||||
fn spawn_agent_common_properties(config: &ToolsConfig) -> BTreeMap<String, JsonSchema> {
|
||||
BTreeMap::from([
|
||||
(
|
||||
"message".to_string(),
|
||||
JsonSchema::String {
|
||||
@@ -1186,21 +1144,15 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
properties.insert(
|
||||
"task_name".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional task name for the new agent. Use lowercase letters, digits, and underscores."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
])
|
||||
}
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spawn_agent".to_string(),
|
||||
description: format!(
|
||||
r#"
|
||||
fn spawn_agent_tool_description(
|
||||
available_models_description: &str,
|
||||
return_value_description: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"
|
||||
Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work.
|
||||
Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn.
|
||||
Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself.
|
||||
@@ -1235,6 +1187,20 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
- Split implementation into disjoint codebase slices and spawn multiple agents for them in parallel when the write scopes do not overlap.
|
||||
- Delegate verification only when it can run in parallel with ongoing implementation and is likely to catch a concrete risk before final integration.
|
||||
- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."#
|
||||
)
|
||||
}
|
||||
|
||||
fn create_spawn_agent_tool_v1(config: &ToolsConfig) -> ToolSpec {
|
||||
let available_models_description = spawn_agent_models_description(&config.available_models);
|
||||
let return_value_description =
|
||||
"Returns the spawned agent id plus the user-facing nickname when available.";
|
||||
let properties = spawn_agent_common_properties(config);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spawn_agent".to_string(),
|
||||
description: spawn_agent_tool_description(
|
||||
&available_models_description,
|
||||
return_value_description,
|
||||
),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
@@ -1243,7 +1209,38 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(spawn_agent_output_schema(config.multi_agent_v2)),
|
||||
output_schema: Some(spawn_agent_output_schema_v1()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_spawn_agent_tool_v2(config: &ToolsConfig) -> ToolSpec {
|
||||
let available_models_description = spawn_agent_models_description(&config.available_models);
|
||||
let return_value_description = "Returns the canonical task name when the spawned agent was named, otherwise the agent id, plus the user-facing nickname when available.";
|
||||
let mut properties = spawn_agent_common_properties(config);
|
||||
properties.insert(
|
||||
"task_name".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional task name for the new agent. Use lowercase letters, digits, and underscores."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spawn_agent".to_string(),
|
||||
description: spawn_agent_tool_description(
|
||||
&available_models_description,
|
||||
return_value_description,
|
||||
),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(spawn_agent_output_schema_v2()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1405,14 +1402,12 @@ fn create_report_agent_job_result_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_send_input_tool() -> ToolSpec {
|
||||
fn create_send_input_tool_v1() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"target".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
|
||||
),
|
||||
description: Some("Agent id to message (from spawn_agent).".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -1550,7 +1545,35 @@ fn create_resume_agent_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_agent_tool_parameters() -> JsonSchema {
|
||||
fn wait_agent_tool_parameters_v1() -> JsonSchema {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"targets".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::String { description: None }),
|
||||
description: Some(
|
||||
"Agent ids to wait on. Pass multiple ids to wait for whichever finishes first."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(format!(
|
||||
"Optional timeout in milliseconds. Defaults to {DEFAULT_WAIT_TIMEOUT_MS}, min {MIN_WAIT_TIMEOUT_MS}, max {MAX_WAIT_TIMEOUT_MS}. Prefer longer waits (minutes) to avoid busy polling."
|
||||
)),
|
||||
},
|
||||
);
|
||||
|
||||
JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["targets".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_agent_tool_parameters_v2() -> JsonSchema {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"targets".to_string(),
|
||||
@@ -1585,7 +1608,7 @@ fn create_wait_agent_tool_v1() -> ToolSpec {
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: wait_agent_tool_parameters(),
|
||||
parameters: wait_agent_tool_parameters_v1(),
|
||||
output_schema: Some(wait_output_schema_v1()),
|
||||
})
|
||||
}
|
||||
@@ -1597,7 +1620,7 @@ fn create_wait_agent_tool_v2() -> ToolSpec {
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: wait_agent_tool_parameters(),
|
||||
parameters: wait_agent_tool_parameters_v2(),
|
||||
output_schema: Some(wait_output_schema_v2()),
|
||||
})
|
||||
}
|
||||
@@ -1744,7 +1767,30 @@ fn create_request_permissions_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_close_agent_tool() -> ToolSpec {
|
||||
fn create_close_agent_tool_v1() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"target".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Agent id to close (from spawn_agent).".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "close_agent".to_string(),
|
||||
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["target".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(close_agent_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_close_agent_tool_v2() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"target".to_string(),
|
||||
@@ -2125,33 +2171,6 @@ JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
})
|
||||
}
|
||||
|
||||
fn create_artifacts_tool() -> ToolSpec {
|
||||
const ARTIFACTS_FREEFORM_GRAMMAR: &str = r#"
|
||||
start: pragma_source | plain_source
|
||||
|
||||
pragma_source: PRAGMA_LINE NEWLINE js_source
|
||||
plain_source: PLAIN_JS_SOURCE
|
||||
|
||||
js_source: JS_SOURCE
|
||||
|
||||
PRAGMA_LINE: /[ \t]*\/\/ codex-artifact-tool:[^\r\n]*/
|
||||
NEWLINE: /\r?\n/
|
||||
PLAIN_JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
"#;
|
||||
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: "artifacts".to_string(),
|
||||
description: "Runs raw JavaScript against the installed `@oai/artifact-tool` package for creating presentations or spreadsheets. This is plain JavaScript executed by a local Node-compatible runtime with top-level await, not TypeScript: do not use type annotations, `interface`, `type`, or `import type`. Author code the same way you would for `import { Presentation, Workbook, PresentationFile, SpreadsheetFile, FileBlob, ... } from \"@oai/artifact-tool\"`, but omit that import line because the package is preloaded before your code runs. Named exports are copied onto `globalThis`, and the full module namespace is available as `globalThis.artifactTool`. This matches the upstream library-first API: create with `Presentation.create()` / `Workbook.create()`, preview with `presentation.export(...)` or `slide.export(...)`, and save files with `PresentationFile.exportPptx(...)` or `SpreadsheetFile.exportXlsx(...)`. Node built-ins such as `node:fs/promises` may still be imported when needed for saving preview bytes. This is a freeform tool: send raw JavaScript source text, optionally with a first-line pragma like `// codex-artifact-tool: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
|
||||
.to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: ARTIFACTS_FREEFORM_GRAMMAR.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_js_repl_reset_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "js_repl_reset".to_string(),
|
||||
@@ -2347,15 +2366,15 @@ pub(crate) fn mcp_tool_to_openai_tool(
|
||||
fully_qualified_name: String,
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
let (description, input_schema, output_schema) = mcp_tool_to_openai_tool_parts(tool)?;
|
||||
let parsed_tool = parse_mcp_tool(&tool)?;
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name: fully_qualified_name,
|
||||
description,
|
||||
description: parsed_tool.description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: input_schema,
|
||||
output_schema,
|
||||
parameters: parsed_tool.input_schema,
|
||||
output_schema: Some(parsed_tool.output_schema),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2363,14 +2382,14 @@ pub(crate) fn mcp_tool_to_deferred_openai_tool(
|
||||
name: String,
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
let (description, input_schema, _) = mcp_tool_to_openai_tool_parts(tool)?;
|
||||
let parsed_tool = parse_mcp_tool(&tool)?;
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name,
|
||||
description,
|
||||
description: parsed_tool.description,
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: input_schema,
|
||||
parameters: parsed_tool.input_schema,
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
@@ -2390,185 +2409,6 @@ fn dynamic_tool_to_openai_tool(
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the tool input_schema or return an error for invalid schema
|
||||
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
|
||||
let mut input_schema = input_schema.clone();
|
||||
sanitize_json_schema(&mut input_schema);
|
||||
serde_json::from_value::<JsonSchema>(input_schema)
|
||||
}
|
||||
|
||||
fn mcp_tool_to_openai_tool_parts(
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<(String, JsonSchema, Option<JsonValue>), serde_json::Error> {
|
||||
let rmcp::model::Tool {
|
||||
description,
|
||||
input_schema,
|
||||
output_schema,
|
||||
..
|
||||
} = tool;
|
||||
|
||||
let mut serialized_input_schema = serde_json::Value::Object(input_schema.as_ref().clone());
|
||||
|
||||
// OpenAI models mandate the "properties" field in the schema. Some MCP
|
||||
// servers omit it (or set it to null), so we insert an empty object to
|
||||
// match the behavior of the Agents SDK.
|
||||
if let serde_json::Value::Object(obj) = &mut serialized_input_schema
|
||||
&& obj.get("properties").is_none_or(serde_json::Value::is_null)
|
||||
{
|
||||
obj.insert(
|
||||
"properties".to_string(),
|
||||
serde_json::Value::Object(serde_json::Map::new()),
|
||||
);
|
||||
}
|
||||
|
||||
// Serialize to a raw JSON value so we can sanitize schemas coming from MCP
|
||||
// servers. Some servers omit the top-level or nested `type` in JSON
|
||||
// Schemas (e.g. using enum/anyOf), or use unsupported variants like
|
||||
// `integer`. Our internal JsonSchema is a small subset and requires
|
||||
// `type`, so we coerce/sanitize here for compatibility.
|
||||
sanitize_json_schema(&mut serialized_input_schema);
|
||||
let input_schema = serde_json::from_value::<JsonSchema>(serialized_input_schema)?;
|
||||
let structured_content_schema = output_schema
|
||||
.map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone()))
|
||||
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
|
||||
let output_schema = Some(mcp_call_tool_result_output_schema(
|
||||
structured_content_schema,
|
||||
));
|
||||
let description = description.map(Into::into).unwrap_or_default();
|
||||
|
||||
Ok((description, input_schema, output_schema))
|
||||
}
|
||||
|
||||
fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"structuredContent": structured_content_schema,
|
||||
"isError": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"_meta": {}
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
|
||||
/// JsonSchema enum. This function:
|
||||
/// - Ensures every schema object has a "type". If missing, infers it from
|
||||
/// common keywords (properties => object, items => array, enum/const/format => string)
|
||||
/// and otherwise defaults to "string".
|
||||
/// - Fills required child fields (e.g. array items, object properties) with
|
||||
/// permissive defaults when absent.
|
||||
fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
match value {
|
||||
JsonValue::Bool(_) => {
|
||||
// JSON Schema boolean form: true/false. Coerce to an accept-all string.
|
||||
*value = json!({ "type": "string" });
|
||||
}
|
||||
JsonValue::Array(arr) => {
|
||||
for v in arr.iter_mut() {
|
||||
sanitize_json_schema(v);
|
||||
}
|
||||
}
|
||||
JsonValue::Object(map) => {
|
||||
// First, recursively sanitize known nested schema holders
|
||||
if let Some(props) = map.get_mut("properties")
|
||||
&& let Some(props_map) = props.as_object_mut()
|
||||
{
|
||||
for (_k, v) in props_map.iter_mut() {
|
||||
sanitize_json_schema(v);
|
||||
}
|
||||
}
|
||||
if let Some(items) = map.get_mut("items") {
|
||||
sanitize_json_schema(items);
|
||||
}
|
||||
// Some schemas use oneOf/anyOf/allOf - sanitize their entries
|
||||
for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] {
|
||||
if let Some(v) = map.get_mut(combiner) {
|
||||
sanitize_json_schema(v);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize/ensure type
|
||||
let mut ty = map.get("type").and_then(|v| v.as_str()).map(str::to_string);
|
||||
|
||||
// If type is an array (union), pick first supported; else leave to inference
|
||||
if ty.is_none()
|
||||
&& let Some(JsonValue::Array(types)) = map.get("type")
|
||||
{
|
||||
for t in types {
|
||||
if let Some(tt) = t.as_str()
|
||||
&& matches!(
|
||||
tt,
|
||||
"object" | "array" | "string" | "number" | "integer" | "boolean"
|
||||
)
|
||||
{
|
||||
ty = Some(tt.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infer type if still missing
|
||||
if ty.is_none() {
|
||||
if map.contains_key("properties")
|
||||
|| map.contains_key("required")
|
||||
|| map.contains_key("additionalProperties")
|
||||
{
|
||||
ty = Some("object".to_string());
|
||||
} else if map.contains_key("items") || map.contains_key("prefixItems") {
|
||||
ty = Some("array".to_string());
|
||||
} else if map.contains_key("enum")
|
||||
|| map.contains_key("const")
|
||||
|| map.contains_key("format")
|
||||
{
|
||||
ty = Some("string".to_string());
|
||||
} else if map.contains_key("minimum")
|
||||
|| map.contains_key("maximum")
|
||||
|| map.contains_key("exclusiveMinimum")
|
||||
|| map.contains_key("exclusiveMaximum")
|
||||
|| map.contains_key("multipleOf")
|
||||
{
|
||||
ty = Some("number".to_string());
|
||||
}
|
||||
}
|
||||
// If we still couldn't infer, default to string
|
||||
let ty = ty.unwrap_or_else(|| "string".to_string());
|
||||
map.insert("type".to_string(), JsonValue::String(ty.to_string()));
|
||||
|
||||
// Ensure object schemas have properties map
|
||||
if ty == "object" {
|
||||
if !map.contains_key("properties") {
|
||||
map.insert(
|
||||
"properties".to_string(),
|
||||
JsonValue::Object(serde_json::Map::new()),
|
||||
);
|
||||
}
|
||||
// If additionalProperties is an object schema, sanitize it too.
|
||||
// Leave booleans as-is, since JSON Schema allows boolean here.
|
||||
if let Some(ap) = map.get_mut("additionalProperties") {
|
||||
let is_bool = matches!(ap, JsonValue::Bool(_));
|
||||
if !is_bool {
|
||||
sanitize_json_schema(ap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure array schemas have items
|
||||
if ty == "array" && !map.contains_key("items") {
|
||||
map.insert("items".to_string(), json!({ "type": "string" }));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tool registry builder while collecting tool specs for later serialization.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_specs(
|
||||
@@ -2588,7 +2428,6 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::ArtifactsHandler;
|
||||
use crate::tools::handlers::CodeModeExecuteHandler;
|
||||
use crate::tools::handlers::CodeModeWaitHandler;
|
||||
use crate::tools::handlers::DynamicToolHandler;
|
||||
@@ -2613,6 +2452,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::multi_agents::SpawnAgentHandler;
|
||||
use crate::tools::handlers::multi_agents::WaitAgentHandler;
|
||||
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
@@ -2639,7 +2479,6 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
let code_mode_wait_handler = Arc::new(CodeModeWaitHandler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
let artifacts_handler = Arc::new(ArtifactsHandler);
|
||||
let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled;
|
||||
|
||||
if config.code_mode_enabled {
|
||||
@@ -2956,67 +2795,38 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
);
|
||||
builder.register_handler("view_image", view_image_handler);
|
||||
|
||||
if config.artifact_tools {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_artifacts_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("artifacts", artifacts_handler);
|
||||
}
|
||||
|
||||
if config.collab_tools {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_spawn_agent_tool(config),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
if config.multi_agent_v2 {
|
||||
create_send_message_tool()
|
||||
} else {
|
||||
create_send_input_tool()
|
||||
},
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
if config.multi_agent_v2 {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_spawn_agent_tool_v2(config),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_send_message_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_assign_task_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
}
|
||||
if !config.multi_agent_v2 {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_resume_agent_tool(),
|
||||
create_wait_agent_tool_v2(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_close_agent_tool_v2(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("resume_agent", Arc::new(ResumeAgentHandler));
|
||||
}
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
if config.multi_agent_v2 {
|
||||
create_wait_agent_tool_v2()
|
||||
} else {
|
||||
create_wait_agent_tool_v1()
|
||||
},
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_close_agent_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
if config.multi_agent_v2 {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_list_agents_tool(),
|
||||
@@ -3027,13 +2837,45 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
builder.register_handler("send_message", Arc::new(SendMessageHandlerV2));
|
||||
builder.register_handler("assign_task", Arc::new(AssignTaskHandlerV2));
|
||||
builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2));
|
||||
builder.register_handler("close_agent", Arc::new(CloseAgentHandlerV2));
|
||||
builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2));
|
||||
} else {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_spawn_agent_tool_v1(config),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_send_input_tool_v1(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_resume_agent_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("resume_agent", Arc::new(ResumeAgentHandler));
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_wait_agent_tool_v1(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_close_agent_tool_v1(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandler));
|
||||
builder.register_handler("wait_agent", Arc::new(WaitAgentHandler));
|
||||
builder.register_handler("close_agent", Arc::new(CloseAgentHandler));
|
||||
}
|
||||
builder.register_handler("close_agent", Arc::new(CloseAgentHandler));
|
||||
}
|
||||
|
||||
if config.agent_jobs_tools {
|
||||
|
||||
@@ -11,6 +11,7 @@ use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_tools::AdditionalProperties;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
@@ -62,139 +63,6 @@ fn search_capable_model_info() -> ModelInfo {
|
||||
model_info
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_to_openai_tool_inserts_empty_properties() {
|
||||
let mut schema = rmcp::model::JsonObject::new();
|
||||
schema.insert("type".to_string(), serde_json::json!("object"));
|
||||
|
||||
let tool = rmcp::model::Tool {
|
||||
name: "no_props".to_string().into(),
|
||||
title: None,
|
||||
description: Some("No properties".to_string().into()),
|
||||
input_schema: std::sync::Arc::new(schema),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let openai_tool =
|
||||
mcp_tool_to_openai_tool("server/no_props".to_string(), tool).expect("convert tool");
|
||||
let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize schema");
|
||||
|
||||
assert_eq!(parameters.get("properties"), Some(&serde_json::json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_to_openai_tool_preserves_top_level_output_schema() {
|
||||
let mut input_schema = rmcp::model::JsonObject::new();
|
||||
input_schema.insert("type".to_string(), serde_json::json!("object"));
|
||||
|
||||
let mut output_schema = rmcp::model::JsonObject::new();
|
||||
output_schema.insert(
|
||||
"properties".to_string(),
|
||||
serde_json::json!({
|
||||
"result": {
|
||||
"properties": {
|
||||
"nested": {}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
output_schema.insert("required".to_string(), serde_json::json!(["result"]));
|
||||
|
||||
let tool = rmcp::model::Tool {
|
||||
name: "with_output".to_string().into(),
|
||||
title: None,
|
||||
description: Some("Has output schema".to_string().into()),
|
||||
input_schema: std::sync::Arc::new(input_schema),
|
||||
output_schema: Some(std::sync::Arc::new(output_schema)),
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_output".to_string(), tool)
|
||||
.expect("convert tool");
|
||||
|
||||
assert_eq!(
|
||||
openai_tool.output_schema,
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"structuredContent": {
|
||||
"properties": {
|
||||
"result": {
|
||||
"properties": {
|
||||
"nested": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["result"]
|
||||
},
|
||||
"isError": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"_meta": {}
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_to_openai_tool_preserves_output_schema_without_inferred_type() {
|
||||
let mut input_schema = rmcp::model::JsonObject::new();
|
||||
input_schema.insert("type".to_string(), serde_json::json!("object"));
|
||||
|
||||
let mut output_schema = rmcp::model::JsonObject::new();
|
||||
output_schema.insert("enum".to_string(), serde_json::json!(["ok", "error"]));
|
||||
|
||||
let tool = rmcp::model::Tool {
|
||||
name: "with_enum_output".to_string().into(),
|
||||
title: None,
|
||||
description: Some("Has enum output schema".to_string().into()),
|
||||
input_schema: std::sync::Arc::new(input_schema),
|
||||
output_schema: Some(std::sync::Arc::new(output_schema)),
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_enum_output".to_string(), tool)
|
||||
.expect("convert tool");
|
||||
|
||||
assert_eq!(
|
||||
openai_tool.output_schema,
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"structuredContent": {
|
||||
"enum": ["ok", "error"]
|
||||
},
|
||||
"isError": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"_meta": {}
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_deferred_tools_always_set_defer_loading_true() {
|
||||
let tool = mcp_tool(
|
||||
@@ -467,17 +335,27 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
search_content_types: None,
|
||||
},
|
||||
create_view_image_tool(config.can_request_original_image_detail),
|
||||
create_spawn_agent_tool(&config),
|
||||
create_send_input_tool(),
|
||||
if config.multi_agent_v2 {
|
||||
create_wait_agent_tool_v2()
|
||||
} else {
|
||||
create_wait_agent_tool_v1()
|
||||
},
|
||||
create_close_agent_tool(),
|
||||
] {
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
}
|
||||
let collab_specs = if config.multi_agent_v2 {
|
||||
vec![
|
||||
create_spawn_agent_tool_v2(&config),
|
||||
create_send_message_tool(),
|
||||
create_wait_agent_tool_v2(),
|
||||
create_close_agent_tool_v2(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
create_spawn_agent_tool_v1(&config),
|
||||
create_send_input_tool_v1(),
|
||||
create_wait_agent_tool_v1(),
|
||||
create_close_agent_tool_v1(),
|
||||
]
|
||||
};
|
||||
for spec in collab_specs {
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
}
|
||||
if !config.multi_agent_v2 {
|
||||
let spec = create_resume_agent_tool();
|
||||
expected.insert(tool_name(&spec).to_string(), spec);
|
||||
@@ -775,28 +653,6 @@ fn view_image_tool_includes_detail_with_original_detail_feature() {
|
||||
assert!(description.contains("omit this field for default resized behavior"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_artifact_tool_enabled() {
|
||||
let mut config = test_config();
|
||||
let runtime_root = tempfile::TempDir::new().expect("create temp codex home");
|
||||
config.codex_home = runtime_root.path().to_path_buf();
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Artifact);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(&tools, &["artifacts"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_agent_job_worker_tools_enabled() {
|
||||
let config = test_config();
|
||||
@@ -2102,12 +1958,12 @@ fn tool_suggest_can_be_registered_without_search_tool() {
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &tool_suggest.spec else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert!(
|
||||
description.contains(
|
||||
"You've already tried to find a matching available tool for the user's request"
|
||||
)
|
||||
);
|
||||
assert!(description.contains("This includes `tool_search` (if available) and other means."));
|
||||
assert!(description.contains(
|
||||
"Suggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin"
|
||||
));
|
||||
assert!(description.contains(
|
||||
"You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2355,6 +2211,9 @@ fn tool_suggest_description_lists_discoverable_tools() {
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert!(description.contains(
|
||||
"Suggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin"
|
||||
));
|
||||
assert!(description.contains("Google Calendar"));
|
||||
assert!(description.contains("Gmail"));
|
||||
assert!(description.contains("Sample Plugin"));
|
||||
@@ -2367,10 +2226,21 @@ fn tool_suggest_description_lists_discoverable_tools() {
|
||||
);
|
||||
assert!(
|
||||
description.contains(
|
||||
"You've already tried to find a matching available tool for the user's request"
|
||||
"You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means."
|
||||
)
|
||||
);
|
||||
assert!(description.contains("This includes `tool_search` (if available) and other means."));
|
||||
assert!(description.contains(
|
||||
"For connectors/apps that are not installed but needed for an installed plugin, suggest to install them if the task requirements match precisely."
|
||||
));
|
||||
assert!(description.contains(
|
||||
"For plugins that are not installed but discoverable, only suggest discoverable and installable plugins when the user's intent very explicitly and unambiguously matches that plugin itself."
|
||||
));
|
||||
assert!(description.contains(
|
||||
"Do not suggest a plugin just because one of its connectors or capabilities seems relevant."
|
||||
));
|
||||
assert!(description.contains(
|
||||
"Apply the stricter explicit-and-unambiguous rule for *discoverable tools* like plugin install suggestions; *missing tools* like connector install suggestions continue to use the normal clear-fit standard."
|
||||
));
|
||||
assert!(description.contains("DO NOT explore or recommend tools that are not on this list."));
|
||||
assert!(!description.contains("tool_search fails to find a good match"));
|
||||
let JsonSchema::Object { required, .. } = parameters else {
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::protocol::ExecCommandSource;
|
||||
use crate::protocol::ExecOutputStream;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventFailure;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::unified_exec::head_tail_buffer::HeadTailBuffer;
|
||||
|
||||
@@ -121,21 +122,36 @@ pub(crate) fn spawn_exit_watcher(
|
||||
exit_token.cancelled().await;
|
||||
output_drained.notified().await;
|
||||
|
||||
let exit_code = process.exit_code().unwrap_or(-1);
|
||||
let duration = Instant::now().saturating_duration_since(started_at);
|
||||
emit_exec_end_for_unified_exec(
|
||||
session_ref,
|
||||
turn_ref,
|
||||
call_id,
|
||||
command,
|
||||
cwd,
|
||||
Some(process_id.to_string()),
|
||||
transcript,
|
||||
String::new(),
|
||||
exit_code,
|
||||
duration,
|
||||
)
|
||||
.await;
|
||||
if let Some(message) = process.failure_message() {
|
||||
emit_failed_exec_end_for_unified_exec(
|
||||
session_ref,
|
||||
turn_ref,
|
||||
call_id,
|
||||
command,
|
||||
cwd,
|
||||
Some(process_id.to_string()),
|
||||
transcript,
|
||||
message,
|
||||
duration,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let exit_code = process.exit_code().unwrap_or(-1);
|
||||
emit_exec_end_for_unified_exec(
|
||||
session_ref,
|
||||
turn_ref,
|
||||
call_id,
|
||||
command,
|
||||
cwd,
|
||||
Some(process_id.to_string()),
|
||||
transcript,
|
||||
String::new(),
|
||||
exit_code,
|
||||
duration,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,6 +229,52 @@ pub(crate) async fn emit_exec_end_for_unified_exec(
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn emit_failed_exec_end_for_unified_exec(
|
||||
session_ref: Arc<Session>,
|
||||
turn_ref: Arc<TurnContext>,
|
||||
call_id: String,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
process_id: Option<String>,
|
||||
transcript: Arc<Mutex<HeadTailBuffer>>,
|
||||
message: String,
|
||||
duration: Duration,
|
||||
) {
|
||||
let stdout = resolve_aggregated_output(&transcript, String::new()).await;
|
||||
let aggregated_output = if stdout.is_empty() {
|
||||
message.clone()
|
||||
} else {
|
||||
format!("{stdout}\n{message}")
|
||||
};
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code: -1,
|
||||
stdout: StreamOutput::new(stdout),
|
||||
stderr: StreamOutput::new(message),
|
||||
aggregated_output: StreamOutput::new(aggregated_output),
|
||||
duration,
|
||||
timed_out: false,
|
||||
};
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
session_ref.as_ref(),
|
||||
turn_ref.as_ref(),
|
||||
&call_id,
|
||||
/*turn_diff_tracker*/ None,
|
||||
);
|
||||
let emitter = ToolEmitter::unified_exec(
|
||||
&command,
|
||||
cwd,
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
process_id,
|
||||
);
|
||||
emitter
|
||||
.emit(
|
||||
event_ctx,
|
||||
ToolEventStage::Failure(ToolEventFailure::Output(output)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn split_valid_utf8_prefix(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
split_valid_utf8_prefix_with_max(buffer, UNIFIED_EXEC_OUTPUT_DELTA_MAX_BYTES)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ use thiserror::Error;
|
||||
pub(crate) enum UnifiedExecError {
|
||||
#[error("Failed to create unified exec process: {message}")]
|
||||
CreateProcess { message: String },
|
||||
#[error("Unified exec process failed: {message}")]
|
||||
ProcessFailed { message: String },
|
||||
// The model is trained on `session_id`, but internally we track a `process_id`.
|
||||
#[error("Unknown process id {process_id}")]
|
||||
UnknownProcessId { process_id: i32 },
|
||||
@@ -28,6 +30,10 @@ impl UnifiedExecError {
|
||||
Self::CreateProcess { message }
|
||||
}
|
||||
|
||||
pub(crate) fn process_failed(message: String) -> Self {
|
||||
Self::ProcessFailed { message }
|
||||
}
|
||||
|
||||
pub(crate) fn sandbox_denied(message: String, output: ExecToolCallOutput) -> Self {
|
||||
Self::SandboxDenied { message, output }
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! This keeps policy logic and user interaction centralized while the PTY/process
|
||||
//! concerns remain isolated here. The implementation is split between:
|
||||
//! - `process.rs`: PTY process lifecycle + output buffering.
|
||||
//! - `process_state.rs`: shared exit/failure state for local and remote processes.
|
||||
//! - `process_manager.rs`: orchestration (approvals, sandboxing, reuse) and request handling.
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -42,6 +43,7 @@ mod errors;
|
||||
mod head_tail_buffer;
|
||||
mod process;
|
||||
mod process_manager;
|
||||
mod process_state;
|
||||
|
||||
pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) {
|
||||
process_manager::set_deterministic_process_ids_for_tests(enabled);
|
||||
@@ -167,6 +169,10 @@ pub(crate) fn generate_chunk_id() -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
#[path = "process_tests.rs"]
|
||||
mod process_tests;
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
#[path = "mod_tests.rs"]
|
||||
|
||||
@@ -3,27 +3,26 @@ use super::*;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
use crate::tools::context::ExecCommandToolOutput;
|
||||
use crate::unified_exec::ExecCommandRequest;
|
||||
use crate::unified_exec::WriteStdinRequest;
|
||||
use crate::unified_exec::process::OutputHandles;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use core_test_support::get_remote_test_env;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::test_env as remote_test_env;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
async fn test_session_and_turn() -> (Arc<Session>, Arc<TurnContext>) {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
turn.approval_policy
|
||||
.set(AskForApproval::Never)
|
||||
.expect("test setup should allow updating approval policy");
|
||||
turn.sandbox_policy
|
||||
.set(SandboxPolicy::DangerFullAccess)
|
||||
.expect("test setup should allow updating sandbox policy");
|
||||
turn.file_system_sandbox_policy =
|
||||
codex_protocol::permissions::FileSystemSandboxPolicy::from(turn.sandbox_policy.get());
|
||||
turn.network_sandbox_policy =
|
||||
codex_protocol::permissions::NetworkSandboxPolicy::from(turn.sandbox_policy.get());
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
(Arc::new(session), Arc::new(turn))
|
||||
}
|
||||
|
||||
@@ -32,36 +31,143 @@ async fn exec_command(
|
||||
turn: &Arc<TurnContext>,
|
||||
cmd: &str,
|
||||
yield_time_ms: u64,
|
||||
workdir: Option<PathBuf>,
|
||||
) -> Result<ExecCommandToolOutput, UnifiedExecError> {
|
||||
exec_command_with_tty(session, turn, cmd, yield_time_ms, workdir, true).await
|
||||
}
|
||||
|
||||
fn shell_env() -> HashMap<String, String> {
|
||||
std::env::vars().collect()
|
||||
}
|
||||
|
||||
fn test_exec_request(
|
||||
turn: &TurnContext,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
env: HashMap<String, String>,
|
||||
) -> ExecRequest {
|
||||
let windows_sandbox_private_desktop = false;
|
||||
let sandbox_policy = turn.sandbox_policy.get().clone();
|
||||
let file_system_sandbox_policy = turn.file_system_sandbox_policy.clone();
|
||||
let network_sandbox_policy = turn.network_sandbox_policy;
|
||||
let network = None;
|
||||
let arg0 = None;
|
||||
ExecRequest::new(
|
||||
command,
|
||||
cwd,
|
||||
env,
|
||||
network,
|
||||
ExecExpiration::DefaultTimeout,
|
||||
ExecCapturePolicy::ShellTool,
|
||||
SandboxType::None,
|
||||
turn.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
arg0,
|
||||
)
|
||||
}
|
||||
|
||||
async fn exec_command_with_tty(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
cmd: &str,
|
||||
yield_time_ms: u64,
|
||||
workdir: Option<PathBuf>,
|
||||
tty: bool,
|
||||
) -> Result<ExecCommandToolOutput, UnifiedExecError> {
|
||||
let manager = &session.services.unified_exec_manager;
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
let cwd = workdir.unwrap_or_else(|| turn.cwd.clone().to_path_buf());
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), cmd.to_string()];
|
||||
let request = test_exec_request(turn, command.clone(), cwd.clone(), shell_env());
|
||||
|
||||
let process = Arc::new(
|
||||
manager
|
||||
.open_session_with_exec_env(
|
||||
process_id,
|
||||
&request,
|
||||
tty,
|
||||
Box::new(NoopSpawnLifecycle),
|
||||
turn.environment.as_ref(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let context =
|
||||
UnifiedExecContext::new(Arc::clone(session), Arc::clone(turn), "call".to_string());
|
||||
let process_id = session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.allocate_process_id()
|
||||
.await;
|
||||
let started_at = Instant::now();
|
||||
let process_started_alive = !process.has_exited() && process.exit_code().is_none();
|
||||
if process_started_alive {
|
||||
let entry = ProcessEntry {
|
||||
process: Arc::clone(&process),
|
||||
call_id: context.call_id.clone(),
|
||||
process_id,
|
||||
command: command.clone(),
|
||||
tty,
|
||||
network_approval_id: None,
|
||||
session: Arc::downgrade(session),
|
||||
last_used: started_at,
|
||||
};
|
||||
manager
|
||||
.process_store
|
||||
.lock()
|
||||
.await
|
||||
.processes
|
||||
.insert(process_id, entry);
|
||||
}
|
||||
|
||||
session
|
||||
.services
|
||||
.unified_exec_manager
|
||||
.exec_command(
|
||||
ExecCommandRequest {
|
||||
command: vec!["bash".to_string(), "-lc".to_string(), cmd.to_string()],
|
||||
process_id,
|
||||
yield_time_ms,
|
||||
max_output_tokens: None,
|
||||
workdir: None,
|
||||
network: None,
|
||||
tty: true,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions: None,
|
||||
additional_permissions_preapproved: false,
|
||||
justification: None,
|
||||
prefix_rule: None,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
let OutputHandles {
|
||||
output_buffer,
|
||||
output_notify,
|
||||
output_closed,
|
||||
output_closed_notify,
|
||||
cancellation_token,
|
||||
} = process.output_handles();
|
||||
let deadline = started_at + Duration::from_millis(yield_time_ms);
|
||||
let collected = UnifiedExecProcessManager::collect_output_until_deadline(
|
||||
&output_buffer,
|
||||
&output_notify,
|
||||
&output_closed,
|
||||
&output_closed_notify,
|
||||
&cancellation_token,
|
||||
Some(session.subscribe_out_of_band_elicitation_pause_state()),
|
||||
deadline,
|
||||
)
|
||||
.await;
|
||||
let wall_time = Instant::now().saturating_duration_since(started_at);
|
||||
let text = String::from_utf8_lossy(&collected).to_string();
|
||||
let has_exited = process.has_exited();
|
||||
let exit_code = process.exit_code();
|
||||
let response_process_id = if process_started_alive && !has_exited {
|
||||
Some(process_id)
|
||||
} else {
|
||||
manager.release_process_id(process_id).await;
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ExecCommandToolOutput {
|
||||
event_call_id: context.call_id,
|
||||
chunk_id: generate_chunk_id(),
|
||||
wall_time,
|
||||
raw_output: collected,
|
||||
max_output_tokens: None,
|
||||
process_id: response_process_id,
|
||||
exit_code,
|
||||
original_token_count: Some(approx_token_count(&text)),
|
||||
session_command: Some(command),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestSpawnLifecycle {
|
||||
inherited_fds: Vec<i32>,
|
||||
}
|
||||
|
||||
impl SpawnLifecycle for TestSpawnLifecycle {
|
||||
fn inherited_fds(&self) -> Vec<i32> {
|
||||
self.inherited_fds.clone()
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_stdin(
|
||||
@@ -121,7 +227,7 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> {
|
||||
|
||||
let (session, turn) = test_session_and_turn().await;
|
||||
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500, None).await?;
|
||||
let process_id = open_shell.process_id.expect("expected process_id");
|
||||
|
||||
write_stdin(
|
||||
@@ -153,7 +259,7 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> {
|
||||
|
||||
let (session, turn) = test_session_and_turn().await;
|
||||
|
||||
let shell_a = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
let shell_a = exec_command(&session, &turn, "bash -i", 2_500, None).await?;
|
||||
let session_a = shell_a.process_id.expect("expected process id");
|
||||
|
||||
write_stdin(
|
||||
@@ -164,7 +270,14 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let out_2 = exec_command(&session, &turn, "echo $CODEX_INTERACTIVE_SHELL_VAR", 2_500).await?;
|
||||
let out_2 = exec_command(
|
||||
&session,
|
||||
&turn,
|
||||
"echo $CODEX_INTERACTIVE_SHELL_VAR",
|
||||
2_500,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
assert!(
|
||||
out_2.process_id.is_none(),
|
||||
@@ -198,7 +311,7 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> {
|
||||
|
||||
let (session, turn) = test_session_and_turn().await;
|
||||
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500, None).await?;
|
||||
let process_id = open_shell.process_id.expect("expected process id");
|
||||
|
||||
write_stdin(
|
||||
@@ -247,7 +360,14 @@ async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> {
|
||||
});
|
||||
|
||||
let started = tokio::time::Instant::now();
|
||||
let response = exec_command(&session, &turn, "sleep 1 && echo unified-exec-done", 250).await?;
|
||||
let response = exec_command(
|
||||
&session,
|
||||
&turn,
|
||||
"sleep 1 && echo unified-exec-done",
|
||||
250,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_secs(2),
|
||||
@@ -270,7 +390,7 @@ async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> {
|
||||
async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> {
|
||||
let (session, turn) = test_session_and_turn().await;
|
||||
|
||||
let result = exec_command(&session, &turn, "echo codex", 120_000).await?;
|
||||
let result = exec_command(&session, &turn, "echo codex", 120_000, None).await?;
|
||||
|
||||
assert!(result.process_id.is_some());
|
||||
assert!(result.truncated_output().contains("codex"));
|
||||
@@ -282,7 +402,7 @@ async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> {
|
||||
#[ignore] // Ignored while we have a better way to test this.
|
||||
async fn completed_commands_do_not_persist_sessions() -> anyhow::Result<()> {
|
||||
let (session, turn) = test_session_and_turn().await;
|
||||
let result = exec_command(&session, &turn, "echo codex", 2_500).await?;
|
||||
let result = exec_command(&session, &turn, "echo codex", 2_500, None).await?;
|
||||
|
||||
assert!(
|
||||
result.process_id.is_some(),
|
||||
@@ -310,7 +430,7 @@ async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<(
|
||||
|
||||
let (session, turn) = test_session_and_turn().await;
|
||||
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500).await?;
|
||||
let open_shell = exec_command(&session, &turn, "bash -i", 2_500, None).await?;
|
||||
let process_id = open_shell.process_id.expect("expected process id");
|
||||
|
||||
write_stdin(&session, process_id, "exit\n", 2_500).await?;
|
||||
@@ -341,3 +461,130 @@ async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> {
|
||||
let (_, turn) = make_session_and_context().await;
|
||||
let request = test_exec_request(
|
||||
&turn,
|
||||
vec!["bash".to_string(), "-lc".to_string(), "exit 17".to_string()],
|
||||
PathBuf::from("/tmp"),
|
||||
shell_env(),
|
||||
);
|
||||
|
||||
let environment = codex_exec_server::Environment::default();
|
||||
let process = UnifiedExecProcessManager::default()
|
||||
.open_session_with_exec_env(
|
||||
1234,
|
||||
&request,
|
||||
false,
|
||||
Box::new(NoopSpawnLifecycle),
|
||||
&environment,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !process.has_exited() {
|
||||
let exit_signal = process.cancellation_token();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(2), exit_signal.cancelled())
|
||||
.await
|
||||
.is_ok(),
|
||||
"process did not report exit within timeout"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(process.has_exited());
|
||||
assert_eq!(process.exit_code(), Some(17));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_uses_remote_exec_server_when_configured() -> anyhow::Result<()> {
|
||||
skip_if_sandbox!(Ok(()));
|
||||
let Some(_remote_env) = get_remote_test_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let remote_test_env = remote_test_env().await?;
|
||||
let (_, turn) = make_session_and_context().await;
|
||||
let request = test_exec_request(
|
||||
&turn,
|
||||
vec!["bash".to_string(), "-i".to_string()],
|
||||
PathBuf::from("/tmp"),
|
||||
shell_env(),
|
||||
);
|
||||
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let process = manager
|
||||
.open_session_with_exec_env(
|
||||
1234,
|
||||
&request,
|
||||
true,
|
||||
Box::new(NoopSpawnLifecycle),
|
||||
remote_test_env.environment(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
process.write(b"printf 'remote-unified-exec\\n'\n").await?;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let crate::unified_exec::process::OutputHandles {
|
||||
output_buffer,
|
||||
output_notify,
|
||||
output_closed,
|
||||
output_closed_notify,
|
||||
cancellation_token,
|
||||
} = process.output_handles();
|
||||
let collected = UnifiedExecProcessManager::collect_output_until_deadline(
|
||||
&output_buffer,
|
||||
&output_notify,
|
||||
&output_closed,
|
||||
&output_closed_notify,
|
||||
&cancellation_token,
|
||||
None,
|
||||
Instant::now() + Duration::from_millis(2_500),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(String::from_utf8_lossy(&collected).contains("remote-unified-exec"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()> {
|
||||
skip_if_sandbox!(Ok(()));
|
||||
let Some(_remote_env) = get_remote_test_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let remote_test_env = remote_test_env().await?;
|
||||
let (_, mut turn) = make_session_and_context().await;
|
||||
turn.environment = Arc::new(remote_test_env.environment().clone());
|
||||
|
||||
let request = test_exec_request(
|
||||
&turn,
|
||||
vec!["bash".to_string(), "-lc".to_string(), "echo ok".to_string()],
|
||||
PathBuf::from("/tmp"),
|
||||
shell_env(),
|
||||
);
|
||||
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let err = manager
|
||||
.open_session_with_exec_env(
|
||||
1234,
|
||||
&request,
|
||||
true,
|
||||
Box::new(TestSpawnLifecycle {
|
||||
inherited_fds: vec![42],
|
||||
}),
|
||||
turn.environment.as_ref(),
|
||||
)
|
||||
.await
|
||||
.expect_err("expected inherited fd rejection");
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Failed to create unified exec process: remote exec-server does not support inherited file descriptors"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ use std::sync::atomic::Ordering;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::Duration;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -15,8 +15,12 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::exec::is_likely_sandbox_denied;
|
||||
use codex_exec_server::ExecProcess;
|
||||
use codex_exec_server::ReadResponse as ExecReadResponse;
|
||||
use codex_exec_server::StartedExecProcess;
|
||||
use codex_exec_server::WriteStatus;
|
||||
use codex_protocol::protocol::TruncationPolicy;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text;
|
||||
use codex_utils_pty::ExecCommandSession;
|
||||
use codex_utils_pty::SpawnedPty;
|
||||
@@ -24,6 +28,9 @@ use codex_utils_pty::SpawnedPty;
|
||||
use super::UNIFIED_EXEC_OUTPUT_MAX_TOKENS;
|
||||
use super::UnifiedExecError;
|
||||
use super::head_tail_buffer::HeadTailBuffer;
|
||||
use super::process_state::ProcessState;
|
||||
|
||||
const EARLY_EXIT_GRACE_PERIOD: Duration = Duration::from_millis(150);
|
||||
|
||||
pub(crate) trait SpawnLifecycle: std::fmt::Debug + Send + Sync {
|
||||
/// Returns file descriptors that must stay open across the child `exec()`.
|
||||
@@ -41,11 +48,13 @@ pub(crate) trait SpawnLifecycle: std::fmt::Debug + Send + Sync {
|
||||
pub(crate) type SpawnLifecycleHandle = Box<dyn SpawnLifecycle>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// Spawn lifecycle that performs no extra setup around process launch.
|
||||
pub(crate) struct NoopSpawnLifecycle;
|
||||
|
||||
impl SpawnLifecycle for NoopSpawnLifecycle {}
|
||||
|
||||
pub(crate) type OutputBuffer = Arc<Mutex<HeadTailBuffer>>;
|
||||
/// Shared output state exposed to polling and streaming consumers.
|
||||
pub(crate) struct OutputHandles {
|
||||
pub(crate) output_buffer: OutputBuffer,
|
||||
pub(crate) output_notify: Arc<Notify>,
|
||||
@@ -54,27 +63,44 @@ pub(crate) struct OutputHandles {
|
||||
pub(crate) cancellation_token: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Transport-specific process handle used by unified exec.
|
||||
enum ProcessHandle {
|
||||
Local(Box<ExecCommandSession>),
|
||||
Remote(Arc<dyn ExecProcess>),
|
||||
}
|
||||
|
||||
/// Unified wrapper over local PTY sessions and exec-server-backed processes.
|
||||
pub(crate) struct UnifiedExecProcess {
|
||||
process_handle: ExecCommandSession,
|
||||
output_rx: broadcast::Receiver<Vec<u8>>,
|
||||
process_handle: ProcessHandle,
|
||||
output_tx: broadcast::Sender<Vec<u8>>,
|
||||
output_buffer: OutputBuffer,
|
||||
output_notify: Arc<Notify>,
|
||||
output_closed: Arc<AtomicBool>,
|
||||
output_closed_notify: Arc<Notify>,
|
||||
cancellation_token: CancellationToken,
|
||||
output_drained: Arc<Notify>,
|
||||
output_task: JoinHandle<()>,
|
||||
state_tx: watch::Sender<ProcessState>,
|
||||
state_rx: watch::Receiver<ProcessState>,
|
||||
output_task: Option<JoinHandle<()>>,
|
||||
sandbox_type: SandboxType,
|
||||
_spawn_lifecycle: SpawnLifecycleHandle,
|
||||
_spawn_lifecycle: Option<SpawnLifecycleHandle>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UnifiedExecProcess {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UnifiedExecProcess")
|
||||
.field("has_exited", &self.has_exited())
|
||||
.field("exit_code", &self.exit_code())
|
||||
.field("sandbox_type", &self.sandbox_type)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl UnifiedExecProcess {
|
||||
pub(super) fn new(
|
||||
process_handle: ExecCommandSession,
|
||||
initial_output_rx: tokio::sync::broadcast::Receiver<Vec<u8>>,
|
||||
fn new(
|
||||
process_handle: ProcessHandle,
|
||||
sandbox_type: SandboxType,
|
||||
spawn_lifecycle: SpawnLifecycleHandle,
|
||||
spawn_lifecycle: Option<SpawnLifecycleHandle>,
|
||||
) -> Self {
|
||||
let output_buffer = Arc::new(Mutex::new(HeadTailBuffer::default()));
|
||||
let output_notify = Arc::new(Notify::new());
|
||||
@@ -82,48 +108,49 @@ impl UnifiedExecProcess {
|
||||
let output_closed_notify = Arc::new(Notify::new());
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let output_drained = Arc::new(Notify::new());
|
||||
let mut receiver = initial_output_rx;
|
||||
let output_rx = receiver.resubscribe();
|
||||
let buffer_clone = Arc::clone(&output_buffer);
|
||||
let notify_clone = Arc::clone(&output_notify);
|
||||
let output_closed_clone = Arc::clone(&output_closed);
|
||||
let output_closed_notify_clone = Arc::clone(&output_closed_notify);
|
||||
let output_task = tokio::spawn(async move {
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Ok(chunk) => {
|
||||
let mut guard = buffer_clone.lock().await;
|
||||
guard.push_chunk(chunk);
|
||||
drop(guard);
|
||||
notify_clone.notify_waiters();
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
output_closed_clone.store(true, Ordering::Release);
|
||||
output_closed_notify_clone.notify_waiters();
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
let (output_tx, _) = broadcast::channel(64);
|
||||
let (state_tx, state_rx) = watch::channel(ProcessState::default());
|
||||
|
||||
Self {
|
||||
process_handle,
|
||||
output_rx,
|
||||
output_tx,
|
||||
output_buffer,
|
||||
output_notify,
|
||||
output_closed,
|
||||
output_closed_notify,
|
||||
cancellation_token,
|
||||
output_drained,
|
||||
output_task,
|
||||
state_tx,
|
||||
state_rx,
|
||||
output_task: None,
|
||||
sandbox_type,
|
||||
_spawn_lifecycle: spawn_lifecycle,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
|
||||
self.process_handle.writer_sender()
|
||||
pub(super) async fn write(&self, data: &[u8]) -> Result<(), UnifiedExecError> {
|
||||
match &self.process_handle {
|
||||
ProcessHandle::Local(process_handle) => process_handle
|
||||
.writer_sender()
|
||||
.send(data.to_vec())
|
||||
.await
|
||||
.map_err(|_| UnifiedExecError::WriteToStdin),
|
||||
ProcessHandle::Remote(process_handle) => {
|
||||
match process_handle.write(data.to_vec()).await {
|
||||
Ok(response) => match response.status {
|
||||
WriteStatus::Accepted => Ok(()),
|
||||
WriteStatus::UnknownProcess | WriteStatus::StdinClosed => {
|
||||
let state = self.state_rx.borrow().clone();
|
||||
let _ = self.state_tx.send_replace(state.exited(state.exit_code));
|
||||
self.cancellation_token.cancel();
|
||||
Err(UnifiedExecError::WriteToStdin)
|
||||
}
|
||||
WriteStatus::Starting => Err(UnifiedExecError::WriteToStdin),
|
||||
},
|
||||
Err(err) => Err(UnifiedExecError::process_failed(err.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn output_handles(&self) -> OutputHandles {
|
||||
@@ -137,7 +164,7 @@ impl UnifiedExecProcess {
|
||||
}
|
||||
|
||||
pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver<Vec<u8>> {
|
||||
self.output_rx.resubscribe()
|
||||
self.output_tx.subscribe()
|
||||
}
|
||||
|
||||
pub(super) fn cancellation_token(&self) -> CancellationToken {
|
||||
@@ -149,19 +176,39 @@ impl UnifiedExecProcess {
|
||||
}
|
||||
|
||||
pub(super) fn has_exited(&self) -> bool {
|
||||
self.process_handle.has_exited()
|
||||
let state = self.state_rx.borrow().clone();
|
||||
match &self.process_handle {
|
||||
ProcessHandle::Local(process_handle) => state.has_exited || process_handle.has_exited(),
|
||||
ProcessHandle::Remote(_) => state.has_exited,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exit_code(&self) -> Option<i32> {
|
||||
self.process_handle.exit_code()
|
||||
let state = self.state_rx.borrow().clone();
|
||||
match &self.process_handle {
|
||||
ProcessHandle::Local(process_handle) => {
|
||||
state.exit_code.or_else(|| process_handle.exit_code())
|
||||
}
|
||||
ProcessHandle::Remote(_) => state.exit_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn terminate(&self) {
|
||||
self.output_closed.store(true, Ordering::Release);
|
||||
self.output_closed_notify.notify_waiters();
|
||||
self.process_handle.terminate();
|
||||
match &self.process_handle {
|
||||
ProcessHandle::Local(process_handle) => process_handle.terminate(),
|
||||
ProcessHandle::Remote(process_handle) => {
|
||||
let process_handle = Arc::clone(process_handle);
|
||||
tokio::spawn(async move {
|
||||
let _ = process_handle.terminate().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
self.cancellation_token.cancel();
|
||||
self.output_task.abort();
|
||||
if let Some(output_task) = &self.output_task {
|
||||
output_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn snapshot_output(&self) -> Vec<Vec<u8>> {
|
||||
@@ -173,6 +220,10 @@ impl UnifiedExecProcess {
|
||||
self.sandbox_type
|
||||
}
|
||||
|
||||
pub(super) fn failure_message(&self) -> Option<String> {
|
||||
self.state_rx.borrow().failure_message.clone()
|
||||
}
|
||||
|
||||
pub(super) async fn check_for_sandbox_denial(&self) -> Result<(), UnifiedExecError> {
|
||||
let _ =
|
||||
tokio::time::timeout(Duration::from_millis(20), self.output_notify.notified()).await;
|
||||
@@ -232,29 +283,47 @@ impl UnifiedExecProcess {
|
||||
mut exit_rx,
|
||||
} = spawned;
|
||||
let output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
|
||||
let managed = Self::new(process_handle, output_rx, sandbox_type, spawn_lifecycle);
|
||||
let mut managed = Self::new(
|
||||
ProcessHandle::Local(Box::new(process_handle)),
|
||||
sandbox_type,
|
||||
Some(spawn_lifecycle),
|
||||
);
|
||||
managed.output_task = Some(Self::spawn_local_output_task(
|
||||
output_rx,
|
||||
Arc::clone(&managed.output_buffer),
|
||||
Arc::clone(&managed.output_notify),
|
||||
Arc::clone(&managed.output_closed),
|
||||
Arc::clone(&managed.output_closed_notify),
|
||||
managed.output_tx.clone(),
|
||||
));
|
||||
|
||||
let exit_ready = matches!(exit_rx.try_recv(), Ok(_) | Err(TryRecvError::Closed));
|
||||
|
||||
if exit_ready {
|
||||
managed.signal_exit();
|
||||
managed.check_for_sandbox_denial().await?;
|
||||
return Ok(managed);
|
||||
match exit_rx.try_recv() {
|
||||
Ok(exit_code) => {
|
||||
managed.signal_exit(Some(exit_code));
|
||||
managed.check_for_sandbox_denial().await?;
|
||||
return Ok(managed);
|
||||
}
|
||||
Err(TryRecvError::Closed) => {
|
||||
managed.signal_exit(/*exit_code*/ None);
|
||||
managed.check_for_sandbox_denial().await?;
|
||||
return Ok(managed);
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
|
||||
if tokio::time::timeout(Duration::from_millis(150), &mut exit_rx)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
managed.signal_exit();
|
||||
if let Ok(exit_result) = tokio::time::timeout(EARLY_EXIT_GRACE_PERIOD, &mut exit_rx).await {
|
||||
managed.signal_exit(exit_result.ok());
|
||||
managed.check_for_sandbox_denial().await?;
|
||||
return Ok(managed);
|
||||
}
|
||||
|
||||
tokio::spawn({
|
||||
let state_tx = managed.state_tx.clone();
|
||||
let cancellation_token = managed.cancellation_token.clone();
|
||||
async move {
|
||||
let _ = exit_rx.await;
|
||||
let exit_code = exit_rx.await.ok();
|
||||
let state = state_tx.borrow().clone();
|
||||
let _ = state_tx.send_replace(state.exited(exit_code));
|
||||
cancellation_token.cancel();
|
||||
}
|
||||
});
|
||||
@@ -262,7 +331,162 @@ impl UnifiedExecProcess {
|
||||
Ok(managed)
|
||||
}
|
||||
|
||||
fn signal_exit(&self) {
|
||||
pub(super) async fn from_remote_started(
|
||||
started: StartedExecProcess,
|
||||
sandbox_type: SandboxType,
|
||||
) -> Result<Self, UnifiedExecError> {
|
||||
let process_handle = ProcessHandle::Remote(Arc::clone(&started.process));
|
||||
let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None);
|
||||
let output_handles = managed.output_handles();
|
||||
managed.output_task = Some(Self::spawn_remote_output_task(
|
||||
started,
|
||||
output_handles,
|
||||
managed.output_tx.clone(),
|
||||
managed.state_tx.clone(),
|
||||
));
|
||||
|
||||
let mut state_rx = managed.state_rx.clone();
|
||||
if tokio::time::timeout(EARLY_EXIT_GRACE_PERIOD, async {
|
||||
loop {
|
||||
let state = state_rx.borrow().clone();
|
||||
if state.has_exited || state.failure_message.is_some() {
|
||||
break;
|
||||
}
|
||||
if state_rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
managed.check_for_sandbox_denial().await?;
|
||||
}
|
||||
|
||||
Ok(managed)
|
||||
}
|
||||
|
||||
fn spawn_remote_output_task(
|
||||
started: StartedExecProcess,
|
||||
output_handles: OutputHandles,
|
||||
output_tx: broadcast::Sender<Vec<u8>>,
|
||||
state_tx: watch::Sender<ProcessState>,
|
||||
) -> JoinHandle<()> {
|
||||
let OutputHandles {
|
||||
output_buffer,
|
||||
output_notify,
|
||||
output_closed,
|
||||
output_closed_notify,
|
||||
cancellation_token,
|
||||
} = output_handles;
|
||||
let process = started.process;
|
||||
let mut wake_rx = process.subscribe_wake();
|
||||
tokio::spawn(async move {
|
||||
let mut after_seq = None;
|
||||
loop {
|
||||
match process
|
||||
.read(after_seq, /*max_bytes*/ None, /*wait_ms*/ Some(0))
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let ExecReadResponse {
|
||||
chunks,
|
||||
next_seq,
|
||||
exited,
|
||||
exit_code,
|
||||
closed,
|
||||
failure,
|
||||
} = response;
|
||||
|
||||
for chunk in chunks {
|
||||
let bytes = chunk.chunk.into_inner();
|
||||
let mut guard = output_buffer.lock().await;
|
||||
guard.push_chunk(bytes.clone());
|
||||
drop(guard);
|
||||
let _ = output_tx.send(bytes);
|
||||
output_notify.notify_waiters();
|
||||
}
|
||||
|
||||
if let Some(message) = failure {
|
||||
let state = state_tx.borrow().clone();
|
||||
let _ = state_tx.send_replace(state.failed(message));
|
||||
output_closed.store(true, Ordering::Release);
|
||||
output_closed_notify.notify_waiters();
|
||||
cancellation_token.cancel();
|
||||
break;
|
||||
}
|
||||
|
||||
if exited {
|
||||
let state = state_tx.borrow().clone();
|
||||
let _ = state_tx.send_replace(state.exited(exit_code));
|
||||
}
|
||||
|
||||
if closed {
|
||||
output_closed.store(true, Ordering::Release);
|
||||
output_closed_notify.notify_waiters();
|
||||
cancellation_token.cancel();
|
||||
}
|
||||
|
||||
after_seq = next_seq.checked_sub(1);
|
||||
if output_closed.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let state = state_tx.borrow().clone();
|
||||
let _ = state_tx.send_replace(state.failed(err.to_string()));
|
||||
output_closed.store(true, Ordering::Release);
|
||||
output_closed_notify.notify_waiters();
|
||||
cancellation_token.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if wake_rx.changed().await.is_err() {
|
||||
let state = state_tx.borrow().clone();
|
||||
let _ = state_tx
|
||||
.send_replace(state.failed("exec-server wake channel closed".to_string()));
|
||||
output_closed.store(true, Ordering::Release);
|
||||
output_closed_notify.notify_waiters();
|
||||
cancellation_token.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_local_output_task(
|
||||
mut receiver: tokio::sync::broadcast::Receiver<Vec<u8>>,
|
||||
buffer: OutputBuffer,
|
||||
output_notify: Arc<Notify>,
|
||||
output_closed: Arc<AtomicBool>,
|
||||
output_closed_notify: Arc<Notify>,
|
||||
output_tx: broadcast::Sender<Vec<u8>>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Ok(chunk) => {
|
||||
let mut guard = buffer.lock().await;
|
||||
guard.push_chunk(chunk.clone());
|
||||
drop(guard);
|
||||
let _ = output_tx.send(chunk);
|
||||
output_notify.notify_waiters();
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
output_closed.store(true, Ordering::Release);
|
||||
output_closed_notify.notify_waiters();
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn signal_exit(&self, exit_code: Option<i32>) {
|
||||
let state = self.state_rx.borrow().clone();
|
||||
let _ = self.state_tx.send_replace(state.exited(exit_code));
|
||||
self.cancellation_token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
@@ -40,6 +39,7 @@ use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use crate::unified_exec::WARNING_UNIFIED_EXEC_PROCESSES;
|
||||
use crate::unified_exec::WriteStdinRequest;
|
||||
use crate::unified_exec::async_watcher::emit_exec_end_for_unified_exec;
|
||||
use crate::unified_exec::async_watcher::emit_failed_exec_end_for_unified_exec;
|
||||
use crate::unified_exec::async_watcher::spawn_exit_watcher;
|
||||
use crate::unified_exec::async_watcher::start_streaming_output;
|
||||
use crate::unified_exec::clamp_yield_time;
|
||||
@@ -89,8 +89,9 @@ fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, S
|
||||
env
|
||||
}
|
||||
|
||||
/// Borrowed process state prepared for a `write_stdin` or poll operation.
|
||||
struct PreparedProcessHandles {
|
||||
writer_tx: mpsc::Sender<Vec<u8>>,
|
||||
process: Arc<UnifiedExecProcess>,
|
||||
output_buffer: OutputBuffer,
|
||||
output_notify: Arc<Notify>,
|
||||
output_closed: Arc<AtomicBool>,
|
||||
@@ -102,6 +103,10 @@ struct PreparedProcessHandles {
|
||||
tty: bool,
|
||||
}
|
||||
|
||||
fn exec_server_process_id(process_id: i32) -> String {
|
||||
process_id.to_string()
|
||||
}
|
||||
|
||||
impl UnifiedExecProcessManager {
|
||||
pub(crate) async fn allocate_process_id(&self) -> i32 {
|
||||
loop {
|
||||
@@ -243,6 +248,29 @@ impl UnifiedExecProcessManager {
|
||||
|
||||
let text = String::from_utf8_lossy(&collected).to_string();
|
||||
let chunk_id = generate_chunk_id();
|
||||
if let Some(message) = process.failure_message() {
|
||||
if !process_started_alive {
|
||||
emit_failed_exec_end_for_unified_exec(
|
||||
Arc::clone(&context.session),
|
||||
Arc::clone(&context.turn),
|
||||
context.call_id.clone(),
|
||||
request.command.clone(),
|
||||
cwd.clone(),
|
||||
Some(request.process_id.to_string()),
|
||||
Arc::clone(&transcript),
|
||||
message.clone(),
|
||||
wall_time,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
self.release_process_id(request.process_id).await;
|
||||
finish_deferred_network_approval(
|
||||
context.session.as_ref(),
|
||||
deferred_network_approval.take(),
|
||||
)
|
||||
.await;
|
||||
return Err(UnifiedExecError::process_failed(message));
|
||||
}
|
||||
let process_id = request.process_id;
|
||||
let (response_process_id, exit_code) = if process_started_alive {
|
||||
match self.refresh_process_state(process_id).await {
|
||||
@@ -312,7 +340,7 @@ impl UnifiedExecProcessManager {
|
||||
let process_id = request.process_id;
|
||||
|
||||
let PreparedProcessHandles {
|
||||
writer_tx,
|
||||
process,
|
||||
output_buffer,
|
||||
output_notify,
|
||||
output_closed,
|
||||
@@ -324,15 +352,31 @@ impl UnifiedExecProcessManager {
|
||||
tty,
|
||||
..
|
||||
} = self.prepare_process_handles(process_id).await?;
|
||||
let mut status_after_write = None;
|
||||
|
||||
if !request.input.is_empty() {
|
||||
if !tty {
|
||||
return Err(UnifiedExecError::StdinClosed);
|
||||
}
|
||||
Self::send_input(&writer_tx, request.input.as_bytes()).await?;
|
||||
// Give the remote process a brief window to react so that we are
|
||||
// more likely to capture its output in the poll below.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
match process.write(request.input.as_bytes()).await {
|
||||
Ok(()) => {
|
||||
// Give the remote process a brief window to react so that we are
|
||||
// more likely to capture its output in the poll below.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let status = self.refresh_process_state(process_id).await;
|
||||
if matches!(status, ProcessStatus::Exited { .. }) {
|
||||
status_after_write = Some(status);
|
||||
} else if matches!(err, UnifiedExecError::ProcessFailed { .. }) {
|
||||
process.terminate();
|
||||
self.release_process_id(process_id).await;
|
||||
return Err(err);
|
||||
} else {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let yield_time_ms = {
|
||||
@@ -362,12 +406,20 @@ impl UnifiedExecProcessManager {
|
||||
let text = String::from_utf8_lossy(&collected).to_string();
|
||||
let original_token_count = approx_token_count(&text);
|
||||
let chunk_id = generate_chunk_id();
|
||||
if let Some(message) = process.failure_message() {
|
||||
self.release_process_id(process_id).await;
|
||||
return Err(UnifiedExecError::process_failed(message));
|
||||
}
|
||||
|
||||
// After polling, refresh_process_state tells us whether the PTY is
|
||||
// still alive or has exited and been removed from the store; we thread
|
||||
// that through so the handler can tag TerminalInteraction with an
|
||||
// appropriate process_id and exit_code.
|
||||
let status = self.refresh_process_state(process_id).await;
|
||||
let status = if let Some(status) = status_after_write {
|
||||
status
|
||||
} else {
|
||||
self.refresh_process_state(process_id).await
|
||||
};
|
||||
let (process_id, exit_code, event_call_id) = match status {
|
||||
ProcessStatus::Alive {
|
||||
exit_code,
|
||||
@@ -455,7 +507,7 @@ impl UnifiedExecProcessManager {
|
||||
.map(|session| session.subscribe_out_of_band_elicitation_pause_state());
|
||||
|
||||
Ok(PreparedProcessHandles {
|
||||
writer_tx: entry.process.writer_sender(),
|
||||
process: Arc::clone(&entry.process),
|
||||
output_buffer,
|
||||
output_notify,
|
||||
output_closed,
|
||||
@@ -468,16 +520,6 @@ impl UnifiedExecProcessManager {
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_input(
|
||||
writer_tx: &mpsc::Sender<Vec<u8>>,
|
||||
data: &[u8],
|
||||
) -> Result<(), UnifiedExecError> {
|
||||
writer_tx
|
||||
.send(data.to_vec())
|
||||
.await
|
||||
.map_err(|_| UnifiedExecError::WriteToStdin)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn store_process(
|
||||
&self,
|
||||
@@ -539,9 +581,11 @@ impl UnifiedExecProcessManager {
|
||||
|
||||
pub(crate) async fn open_session_with_exec_env(
|
||||
&self,
|
||||
process_id: i32,
|
||||
env: &ExecRequest,
|
||||
tty: bool,
|
||||
mut spawn_lifecycle: SpawnLifecycleHandle,
|
||||
environment: &codex_exec_server::Environment,
|
||||
) -> Result<UnifiedExecProcess, UnifiedExecError> {
|
||||
let (program, args) = env
|
||||
.command
|
||||
@@ -549,6 +593,28 @@ impl UnifiedExecProcessManager {
|
||||
.ok_or(UnifiedExecError::MissingCommandLine)?;
|
||||
let inherited_fds = spawn_lifecycle.inherited_fds();
|
||||
|
||||
if environment.exec_server_url().is_some() {
|
||||
if !inherited_fds.is_empty() {
|
||||
return Err(UnifiedExecError::create_process(
|
||||
"remote exec-server does not support inherited file descriptors".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let started = environment
|
||||
.get_exec_backend()
|
||||
.start(codex_exec_server::ExecParams {
|
||||
process_id: exec_server_process_id(process_id).into(),
|
||||
argv: env.command.clone(),
|
||||
cwd: env.cwd.clone(),
|
||||
env: env.env.clone(),
|
||||
tty,
|
||||
arg0: env.arg0.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
|
||||
return UnifiedExecProcess::from_remote_started(started, env.sandbox).await;
|
||||
}
|
||||
|
||||
let spawn_result = if tty {
|
||||
codex_utils_pty::pty::spawn_process_with_inherited_fds(
|
||||
program,
|
||||
@@ -611,6 +677,7 @@ impl UnifiedExecProcessManager {
|
||||
.await;
|
||||
let req = UnifiedExecToolRequest {
|
||||
command: request.command.clone(),
|
||||
process_id: request.process_id,
|
||||
cwd,
|
||||
env,
|
||||
explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(),
|
||||
|
||||
@@ -34,6 +34,11 @@ fn unified_exec_env_overrides_existing_values() {
|
||||
assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_server_process_id_matches_unified_exec_process_id() {
|
||||
assert_eq!(exec_server_process_id(4321), "4321");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_prefers_exited_processes_outside_recently_used() {
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) struct ProcessState {
|
||||
pub(crate) has_exited: bool,
|
||||
pub(crate) exit_code: Option<i32>,
|
||||
pub(crate) failure_message: Option<String>,
|
||||
}
|
||||
|
||||
impl ProcessState {
|
||||
pub(crate) fn exited(&self, exit_code: Option<i32>) -> Self {
|
||||
Self {
|
||||
has_exited: true,
|
||||
exit_code,
|
||||
failure_message: self.failure_message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn failed(&self, message: String) -> Self {
|
||||
Self {
|
||||
has_exited: true,
|
||||
exit_code: self.exit_code,
|
||||
failure_message: Some(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use super::process::UnifiedExecProcess;
|
||||
use crate::unified_exec::UnifiedExecError;
|
||||
use async_trait::async_trait;
|
||||
use codex_exec_server::ExecProcess;
|
||||
use codex_exec_server::ExecServerError;
|
||||
use codex_exec_server::ProcessId;
|
||||
use codex_exec_server::ReadResponse;
|
||||
use codex_exec_server::StartedExecProcess;
|
||||
use codex_exec_server::WriteResponse;
|
||||
use codex_exec_server::WriteStatus;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::Duration;
|
||||
|
||||
struct MockExecProcess {
|
||||
process_id: ProcessId,
|
||||
write_response: WriteResponse,
|
||||
read_responses: Mutex<VecDeque<ReadResponse>>,
|
||||
wake_tx: watch::Sender<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecProcess for MockExecProcess {
|
||||
fn process_id(&self) -> &ProcessId {
|
||||
&self.process_id
|
||||
}
|
||||
|
||||
fn subscribe_wake(&self) -> watch::Receiver<u64> {
|
||||
self.wake_tx.subscribe()
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
_after_seq: Option<u64>,
|
||||
_max_bytes: Option<usize>,
|
||||
_wait_ms: Option<u64>,
|
||||
) -> Result<ReadResponse, ExecServerError> {
|
||||
Ok(self
|
||||
.read_responses
|
||||
.lock()
|
||||
.await
|
||||
.pop_front()
|
||||
.unwrap_or(ReadResponse {
|
||||
chunks: Vec::new(),
|
||||
next_seq: 1,
|
||||
exited: false,
|
||||
exit_code: None,
|
||||
closed: false,
|
||||
failure: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn write(&self, _chunk: Vec<u8>) -> Result<WriteResponse, ExecServerError> {
|
||||
Ok(self.write_response.clone())
|
||||
}
|
||||
|
||||
async fn terminate(&self) -> Result<(), ExecServerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn remote_process(write_status: WriteStatus) -> UnifiedExecProcess {
|
||||
let (wake_tx, _wake_rx) = watch::channel(0);
|
||||
let started = StartedExecProcess {
|
||||
process: Arc::new(MockExecProcess {
|
||||
process_id: "test-process".to_string().into(),
|
||||
write_response: WriteResponse {
|
||||
status: write_status,
|
||||
},
|
||||
read_responses: Mutex::new(VecDeque::new()),
|
||||
wake_tx,
|
||||
}),
|
||||
};
|
||||
|
||||
UnifiedExecProcess::from_remote_started(started, SandboxType::None)
|
||||
.await
|
||||
.expect("remote process should start")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_write_unknown_process_marks_process_exited() {
|
||||
let process = remote_process(WriteStatus::UnknownProcess).await;
|
||||
|
||||
let err = process
|
||||
.write(b"hello")
|
||||
.await
|
||||
.expect_err("expected write failure");
|
||||
|
||||
assert!(matches!(err, UnifiedExecError::WriteToStdin));
|
||||
assert!(process.has_exited());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_write_closed_stdin_marks_process_exited() {
|
||||
let process = remote_process(WriteStatus::StdinClosed).await;
|
||||
|
||||
let err = process
|
||||
.write(b"hello")
|
||||
.await
|
||||
.expect_err("expected write failure");
|
||||
|
||||
assert!(matches!(err, UnifiedExecError::WriteToStdin));
|
||||
assert!(process.has_exited());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_process_waits_for_early_exit_event() {
|
||||
let (wake_tx, _wake_rx) = watch::channel(0);
|
||||
let started = StartedExecProcess {
|
||||
process: Arc::new(MockExecProcess {
|
||||
process_id: "test-process".to_string().into(),
|
||||
write_response: WriteResponse {
|
||||
status: WriteStatus::Accepted,
|
||||
},
|
||||
read_responses: Mutex::new(VecDeque::from([ReadResponse {
|
||||
chunks: Vec::new(),
|
||||
next_seq: 2,
|
||||
exited: true,
|
||||
exit_code: Some(17),
|
||||
closed: true,
|
||||
failure: None,
|
||||
}])),
|
||||
wake_tx: wake_tx.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
let _ = wake_tx.send(1);
|
||||
});
|
||||
|
||||
let process = UnifiedExecProcess::from_remote_started(started, SandboxType::None)
|
||||
.await
|
||||
.expect("remote process should observe early exit");
|
||||
|
||||
assert!(process.has_exited());
|
||||
assert_eq!(process.exit_code(), Some(17));
|
||||
}
|
||||
@@ -180,13 +180,15 @@ pub fn run_elevated_setup(
|
||||
codex_home: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
codex_windows_sandbox::run_elevated_setup(
|
||||
policy,
|
||||
policy_cwd,
|
||||
command_cwd,
|
||||
env_map,
|
||||
codex_home,
|
||||
/*read_roots_override*/ None,
|
||||
/*write_roots_override*/ None,
|
||||
codex_windows_sandbox::SandboxSetupRequest {
|
||||
policy,
|
||||
policy_cwd,
|
||||
command_cwd,
|
||||
env_map,
|
||||
codex_home,
|
||||
proxy_enforced: false,
|
||||
},
|
||||
codex_windows_sandbox::SetupRootOverrides::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,6 +236,7 @@ pub fn run_setup_refresh_with_extra_read_roots(
|
||||
env_map,
|
||||
codex_home,
|
||||
extra_read_roots,
|
||||
/*proxy_enforced*/ false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Tool suggestion discovery
|
||||
|
||||
Suggests a discoverable connector or plugin when the user clearly wants a capability that is not currently available in the active `tools` list.
|
||||
Suggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin, when the user clearly wants a capability that is not currently available in the active `tools` list.
|
||||
|
||||
Use this ONLY when:
|
||||
- You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means.
|
||||
- AND the user's request strongly matches one of the discoverable tools listed below.
|
||||
- For connectors/apps that are not installed but needed for an installed plugin, suggest to install them if the task requirements match precisely.
|
||||
- For plugins that are not installed but discoverable, only suggest discoverable and installable plugins when the user's intent very explicitly and unambiguously matches that plugin itself. Do not suggest a plugin just because one of its connectors or capabilities seems relevant.
|
||||
|
||||
Tool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list.
|
||||
|
||||
@@ -14,7 +15,7 @@ Discoverable tools:
|
||||
Workflow:
|
||||
|
||||
1. Ensure all possible means have been exhausted to find an existing available tool but none of them matches the request intent.
|
||||
2. Match the user's request against the discoverable tools list above.
|
||||
2. Match the user's request against the discoverable tools list above. Apply the stricter explicit-and-unambiguous rule for *discoverable tools* like plugin install suggestions; *missing tools* like connector install suggestions continue to use the normal clear-fit standard.
|
||||
3. If one tool clearly fits, call `tool_suggest` with:
|
||||
- `tool_type`: `connector` or `plugin`
|
||||
- `action_type`: `install` or `enable`
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
Create and edit PowerPoint presentation artifacts inside the current thread.
|
||||
|
||||
- This is a stateful built-in tool. `artifact_id` values are returned by earlier calls and persist only for the current thread.
|
||||
- Resume and fork do not restore live artifact state. Export files if you need a durable handoff.
|
||||
- Relative paths resolve from the current working directory.
|
||||
- Position and size values are in slide points.
|
||||
- Every tool call uses a top-level `actions` array of sequential steps. Each call operates on a single top-level `artifact_id` when one is needed. If a call starts with `create` or `import_pptx`, later steps in the same call automatically reuse the returned artifact id.
|
||||
|
||||
Supported actions:
|
||||
- `create`
|
||||
- `import_pptx`
|
||||
- `export_pptx`
|
||||
- `export_preview`
|
||||
- `get_summary`
|
||||
- `list_slides`
|
||||
- `list_layouts`
|
||||
- `list_layout_placeholders`
|
||||
- `list_slide_placeholders`
|
||||
- `inspect`
|
||||
- `resolve`
|
||||
- `to_proto`
|
||||
- `record_patch`
|
||||
- `apply_patch`
|
||||
- `undo`
|
||||
- `redo`
|
||||
- `create_layout`
|
||||
- `add_layout_placeholder`
|
||||
- `set_slide_layout`
|
||||
- `update_placeholder_text`
|
||||
- `set_theme`
|
||||
- `add_style`
|
||||
- `get_style`
|
||||
- `describe_styles`
|
||||
- `set_notes`
|
||||
- `set_notes_rich_text`
|
||||
- `append_notes`
|
||||
- `clear_notes`
|
||||
- `set_notes_visibility`
|
||||
- `set_active_slide`
|
||||
- `add_slide`
|
||||
- `insert_slide`
|
||||
- `duplicate_slide`
|
||||
- `move_slide`
|
||||
- `delete_slide`
|
||||
- `set_slide_background`
|
||||
- `add_text_shape`
|
||||
- `add_shape`
|
||||
- `add_connector`
|
||||
- `add_image`
|
||||
- `replace_image`
|
||||
- `add_table`
|
||||
- `update_table_style`
|
||||
- `style_table_block`
|
||||
- `update_table_cell`
|
||||
- `merge_table_cells`
|
||||
- `add_chart`
|
||||
- `update_chart`
|
||||
- `add_chart_series`
|
||||
- `update_text`
|
||||
- `set_rich_text`
|
||||
- `format_text_range`
|
||||
- `replace_text`
|
||||
- `insert_text_after`
|
||||
- `set_hyperlink`
|
||||
- `set_comment_author`
|
||||
- `add_comment_thread`
|
||||
- `add_comment_reply`
|
||||
- `toggle_comment_reaction`
|
||||
- `resolve_comment_thread`
|
||||
- `reopen_comment_thread`
|
||||
- `update_shape_style`
|
||||
- `bring_to_front`
|
||||
- `send_to_back`
|
||||
- `delete_element`
|
||||
- `delete_artifact`
|
||||
|
||||
Example create:
|
||||
`{"actions":[{"action":"create","args":{"name":"Quarterly Update"}}]}`
|
||||
|
||||
Example create with custom slide size:
|
||||
`{"actions":[{"action":"create","args":{"name":"Quarterly Update","slide_size":{"width":960,"height":540}}}]}`
|
||||
|
||||
Example edit:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}]}`
|
||||
|
||||
Example sequential batch:
|
||||
`{"actions":[{"action":"create","args":{"name":"Quarterly Update"}},{"action":"add_slide","args":{}},{"action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}]}`
|
||||
|
||||
Table creation also accepts optional `column_widths` and `row_heights` arrays in points when you need explicit table sizing instead of even splits. Tables also support `style_options`, `borders`, and `right_to_left`, with `update_table_style` and `style_table_block` available for incremental styling after creation.
|
||||
|
||||
Example export:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"export_pptx","args":{"path":"artifacts/q2-update.pptx"}}]}`
|
||||
|
||||
Example layout flow:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"create_layout","args":{"name":"Title Slide"}}]}`
|
||||
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"add_layout_placeholder","args":{"layout_id":"layout_1","name":"title","placeholder_type":"title","text":"Click to add title","position":{"left":48,"top":48,"width":624,"height":72}}}]}`
|
||||
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"set_slide_layout","args":{"slide_index":0,"layout_id":"layout_1"}}]}`
|
||||
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"list_layout_placeholders","args":{"layout_id":"layout_1"}}]}`
|
||||
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"list_slide_placeholders","args":{"slide_index":0}}]}`
|
||||
|
||||
Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.layout_id`, `add_slide`, `insert_slide`, `set_slide_layout`, and `list_layout_placeholders` accept either a layout id or a layout name. Name matching prefers exact id, then exact name, then case-insensitive name.
|
||||
|
||||
`insert_slide` accepts `index` or `after_slide_index`. If neither is provided, the new slide is inserted immediately after the active slide, or appended if no active slide is set yet.
|
||||
|
||||
Example inspect:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"inspect","args":{"include":"deck,slide,textbox,shape,table,chart,image,notes,layoutList,textRange,comment","exclude":"notes","search":"roadmap","max_chars":12000}}]}`
|
||||
|
||||
Example inspect target window:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"inspect","args":{"include":"textbox","target":{"id":"sh/element_3","before_lines":1,"after_lines":1}}}]}`
|
||||
|
||||
Example resolve:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"resolve","args":{"id":"sh/element_3"}}]}`
|
||||
|
||||
Example proto export:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"to_proto","args":{}}]}`
|
||||
|
||||
`to_proto` returns a full JSON snapshot of the current in-memory presentation document, including slide/layout records, anchors, notes, theme state, and typed element payloads.
|
||||
|
||||
Rich text is supported on notes, text boxes, shapes with text, and table cells. Use `set_rich_text` to replace a full rich-text payload, `set_notes_rich_text` for speaker notes, and `format_text_range` to annotate a substring by `query` or explicit codepoint range. `inspect`, `resolve`, and `to_proto` surface text-range anchors as `tr/<range_id>`.
|
||||
|
||||
Comment threads are supported through `set_comment_author`, `add_comment_thread`, `add_comment_reply`, `toggle_comment_reaction`, `resolve_comment_thread`, and `reopen_comment_thread`. Thread anchors resolve as `th/<thread_id>`, and comment records appear in both `inspect` and `to_proto`.
|
||||
|
||||
Charts support richer series metadata plus `update_chart` and `add_chart_series`, including legend, axis, data-label, marker, fill, and per-point override state.
|
||||
|
||||
Exported PPTX files embed Codex metadata so rich text, comment threads, and advanced table/chart state round-trip through `export_pptx` and `import_pptx` even when the base OOXML representation is lossy.
|
||||
|
||||
Example patch recording:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"record_patch","args":{"operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}]}`
|
||||
|
||||
Example patch application:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"apply_patch","args":{"patch":{"version":1,"artifactId":"presentation_x","operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}}]}`
|
||||
|
||||
Patch payloads are single-artifact and currently support existing in-memory editing actions like slide/element/layout/theme/text updates. Lifecycle, import/export, and nested history actions are intentionally excluded.
|
||||
|
||||
Example undo/redo:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"undo","args":{}}]}`
|
||||
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"redo","args":{}}]}`
|
||||
|
||||
Deck summaries, slide listings, `inspect`, and `resolve` now include active-slide metadata. Use `set_active_slide` to change it explicitly.
|
||||
|
||||
Theme snapshots and `to_proto` both expose the deck theme hex color map via `hex_color_map` / `hexColorMap`.
|
||||
|
||||
Named text styles are supported through `add_style`, `get_style`, and `describe_styles`. Built-in styles include `title`, `heading1`, `body`, `list`, and `numberedList`.
|
||||
|
||||
Example style creation:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"add_style","args":{"name":"callout","font_size":18,"color":"#336699","italic":true,"underline":true}}]}`
|
||||
|
||||
Example style lookup:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"get_style","args":{"name":"title"}}]}`
|
||||
|
||||
Text styling payloads on `add_text_shape`, `add_shape.text_style`, `update_text.styling`, and `update_table_cell.styling` accept `style` and `underline` in addition to the existing whole-element fields.
|
||||
|
||||
Text-bearing elements also support literal `replace_text` and `insert_text_after` helpers for in-place edits without resending the full string.
|
||||
|
||||
Example rich text update:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"set_rich_text","args":{"element_id":"element_3","text":[[{"run":"Quarterly ","text_style":{"bold":true}},"update pipeline"]],"text_layout":{"wrap":"square","auto_fit":"shrinkText","vertical_alignment":"middle","insets":{"left":6,"right":6,"top":4,"bottom":4}}}}]}`
|
||||
|
||||
Example substring formatting:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"format_text_range","args":{"element_id":"element_3","query":"update","styling":{"italic":true},"link":{"uri":"https://example.com/update","is_external":true}}}]}`
|
||||
|
||||
Text boxes and shapes support whole-element hyperlinks via `set_hyperlink`. Supported `link_type` values are `url`, `slide`, `first_slide`, `last_slide`, `next_slide`, `previous_slide`, `end_show`, `email`, and `file`. Use `clear: true` to remove an existing hyperlink.
|
||||
|
||||
Notes visibility is honored on export: `set_notes_visibility` controls whether speaker notes are emitted into exported PPTX output.
|
||||
|
||||
Example comment thread:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"set_comment_author","args":{"display_name":"Jamie Fox","initials":"JF"}},{"action":"add_comment_thread","args":{"slide_index":0,"element_id":"element_3","query":"Quarterly","text":"Tighten this headline"}},{"action":"add_comment_reply","args":{"thread_id":"thread_1","text":"Applied to the draft."}}]}`
|
||||
|
||||
Image placeholders can be prompt-only. `add_image` accepts `prompt` without `path`/`data_url`, and unresolved placeholders export as a visible placeholder box instead of failing.
|
||||
|
||||
Layout placeholders with `placeholder_type: "picture"` or `"image"` materialize as placeholder image elements on slides, so they appear in `list_slide_placeholders`, `inspect`, and `resolve` as `image` records rather than generic shapes.
|
||||
|
||||
Remote images are supported. `add_image` and `replace_image` accept `uri` in addition to local `path`, raw base64 `blob`, and `data_url`.
|
||||
|
||||
Image edits can target inspect/resolve anchors like `im/element_3`, and `update_shape_style` now accepts image `fit`, `crop`, `rotation`, `flip_horizontal`, `flip_vertical`, and `lock_aspect_ratio` updates.
|
||||
|
||||
`add_image` and `replace_image` also accept optional `rotation`, `flip_horizontal`, and `flip_vertical` fields for image transforms.
|
||||
|
||||
`update_shape_style.position` accepts partial updates, so you can move or resize an element without resending the full rect.
|
||||
|
||||
Shape strokes accept an optional `style` field such as `solid`, `dashed`, `dotted`, `dash-dot`, `dash-dot-dot`, `long-dash`, or `long-dash-dot`. This applies to ordinary shapes via `add_shape.stroke` and `update_shape_style.stroke`.
|
||||
|
||||
`add_shape` also accepts optional `rotation`, `flip_horizontal`, and `flip_vertical` fields. Those same transform fields can also be provided inside the `position` object for `add_shape`, `add_image`, and `update_shape_style`.
|
||||
|
||||
Connectors are supported via `add_connector`, with straight/elbow/curved types plus dash styles and arrow heads.
|
||||
|
||||
Example chart update:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"update_chart","args":{"element_id":"element_7","style_index":12,"legend_position":"bottom","y_axis_title":"USD (millions)"}},{"action":"add_chart_series","args":{"element_id":"element_7","name":"Target","values":[11,13],"fill":"#70AD47","marker":{"symbol":"diamond","size":6}}}]}`
|
||||
|
||||
Example preview:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.png"}}]}`
|
||||
|
||||
`export_preview` also accepts `format`, `scale`, and `quality` for rendered previews. `format` currently supports `png`, `jpeg`, and `svg`.
|
||||
|
||||
Example JPEG preview:
|
||||
`{"artifact_id":"presentation_x","actions":[{"action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.jpg","format":"jpeg","scale":0.75,"quality":85}}]}`
|
||||
@@ -208,10 +208,6 @@ fn default_test_overrides() -> ConfigOverrides {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn find_codex_linux_sandbox_exe() -> Result<PathBuf, CargoBinError> {
|
||||
if let Ok(path) = std::env::current_exe() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
if let Some(path) = TEST_ARG0_PATH_ENTRY
|
||||
.get()
|
||||
.and_then(Option::as_ref)
|
||||
@@ -220,6 +216,10 @@ pub fn find_codex_linux_sandbox_exe() -> Result<PathBuf, CargoBinError> {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
if let Ok(path) = std::env::current_exe() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
codex_utils_cargo_bin::cargo_bin("codex-linux-sandbox")
|
||||
}
|
||||
|
||||
|
||||
@@ -105,8 +105,7 @@ impl TestEnv {
|
||||
pub async fn local() -> Result<Self> {
|
||||
let local_cwd_temp_dir = TempDir::new()?;
|
||||
let cwd = local_cwd_temp_dir.path().to_path_buf();
|
||||
let environment =
|
||||
codex_exec_server::Environment::create(/*experimental_exec_server_url*/ None).await?;
|
||||
let environment = codex_exec_server::Environment::create(/*exec_server_url*/ None).await?;
|
||||
Ok(Self {
|
||||
environment,
|
||||
cwd,
|
||||
@@ -119,8 +118,8 @@ impl TestEnv {
|
||||
&self.environment
|
||||
}
|
||||
|
||||
pub fn experimental_exec_server_url(&self) -> Option<&str> {
|
||||
self.environment.experimental_exec_server_url()
|
||||
pub fn exec_server_url(&self) -> Option<&str> {
|
||||
self.environment.exec_server_url()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,17 +389,17 @@ impl TestCodexBuilder {
|
||||
server: &wiremock::MockServer,
|
||||
) -> anyhow::Result<TestCodex> {
|
||||
let test_env = test_env().await?;
|
||||
let experimental_exec_server_url =
|
||||
test_env.experimental_exec_server_url().map(str::to_owned);
|
||||
let home = match self.home.clone() {
|
||||
Some(home) => home,
|
||||
None => Arc::new(TempDir::new()?),
|
||||
};
|
||||
let base_url = format!("{}/v1", server.uri());
|
||||
let cwd = test_env.cwd.to_path_buf();
|
||||
self.config_mutators.push(Box::new(move |config| {
|
||||
config.experimental_exec_server_url = experimental_exec_server_url;
|
||||
config.cwd = cwd.abs();
|
||||
}));
|
||||
|
||||
let mut test = self.build(server).await?;
|
||||
test._test_env = test_env;
|
||||
Ok(test)
|
||||
let (config, cwd) = self.prepare_config(base_url, &home).await?;
|
||||
Box::pin(self.build_from_config(config, cwd, home, /*resume_from*/ None, test_env)).await
|
||||
}
|
||||
|
||||
pub async fn build_with_streaming_server(
|
||||
@@ -479,18 +478,23 @@ impl TestCodexBuilder {
|
||||
test_env: TestEnv,
|
||||
) -> anyhow::Result<TestCodex> {
|
||||
let auth = self.auth.clone();
|
||||
let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
test_env.exec_server_url().map(str::to_owned),
|
||||
));
|
||||
let thread_manager = if config.model_catalog.is_some() {
|
||||
ThreadManager::new(
|
||||
&config,
|
||||
codex_core::test_support::auth_manager_from_auth(auth.clone()),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
Arc::clone(&environment_manager),
|
||||
)
|
||||
} else {
|
||||
codex_core::test_support::thread_manager_with_models_provider_and_home(
|
||||
auth.clone(),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&environment_manager),
|
||||
)
|
||||
};
|
||||
let thread_manager = Arc::new(thread_manager);
|
||||
@@ -564,15 +568,21 @@ impl TestCodexBuilder {
|
||||
hook(home.path());
|
||||
}
|
||||
if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") {
|
||||
config.codex_linux_sandbox_exe = Some(path);
|
||||
config.codex_self_exe = Some(path);
|
||||
} else if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex-exec") {
|
||||
// `codex-exec` also supports `--codex-run-as-apply-patch`, so use it
|
||||
// when the multitool binary is not available in test builds.
|
||||
config.codex_self_exe = Some(path);
|
||||
} else if let Ok(exe) = std::env::current_exe()
|
||||
&& let Some(path) = exe
|
||||
.parent()
|
||||
.and_then(|parent| parent.parent())
|
||||
.map(|parent| parent.join("codex"))
|
||||
&& path.is_file()
|
||||
&& let Some(bin_dir) = exe.parent().and_then(|parent| parent.parent())
|
||||
{
|
||||
config.codex_linux_sandbox_exe = Some(path);
|
||||
let codex = bin_dir.join("codex");
|
||||
let codex_exec = bin_dir.join("codex-exec");
|
||||
if codex.is_file() {
|
||||
config.codex_self_exe = Some(codex);
|
||||
} else if codex_exec.is_file() {
|
||||
config.codex_self_exe = Some(codex_exec);
|
||||
}
|
||||
}
|
||||
|
||||
let mut mutators = vec![];
|
||||
@@ -782,7 +792,9 @@ impl TestCodexHarness {
|
||||
}
|
||||
|
||||
pub async fn submit(&self, prompt: &str) -> Result<()> {
|
||||
self.test.submit_turn(prompt).await
|
||||
// Box the submit-and-wait path so callers do not inline the full turn
|
||||
// future into their own async state.
|
||||
Box::pin(self.test.submit_turn(prompt)).await
|
||||
}
|
||||
|
||||
pub async fn submit_with_policy(
|
||||
@@ -834,13 +846,17 @@ impl TestCodexHarness {
|
||||
call_id: &str,
|
||||
output_type: ApplyPatchModelOutput,
|
||||
) -> String {
|
||||
// Box the awaited output helpers so callers do not inline request
|
||||
// capture and response parsing into their own async state.
|
||||
match output_type {
|
||||
ApplyPatchModelOutput::Freeform => self.custom_tool_call_output(call_id).await,
|
||||
ApplyPatchModelOutput::Freeform => {
|
||||
Box::pin(self.custom_tool_call_output(call_id)).await
|
||||
}
|
||||
ApplyPatchModelOutput::Function
|
||||
| ApplyPatchModelOutput::Shell
|
||||
| ApplyPatchModelOutput::ShellViaHeredoc
|
||||
| ApplyPatchModelOutput::ShellCommandViaHeredoc => {
|
||||
self.function_call_stdout(call_id).await
|
||||
Box::pin(self.function_call_stdout(call_id)).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_test_macros::large_stack_test;
|
||||
use core_test_support::responses::ev_apply_patch_call;
|
||||
use core_test_support::responses::ev_apply_patch_custom_tool_call;
|
||||
use core_test_support::responses::ev_shell_command_call;
|
||||
@@ -19,6 +18,8 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
#[cfg(target_os = "linux")]
|
||||
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses::ev_apply_patch_function_call;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -51,7 +52,9 @@ async fn apply_patch_harness_with(
|
||||
let builder = configure(test_codex()).with_config(|config| {
|
||||
config.include_apply_patch_tool = true;
|
||||
});
|
||||
TestCodexHarness::with_builder(builder).await
|
||||
// Box harness construction so apply_patch_cli tests do not inline the
|
||||
// full test-thread startup path into each test future.
|
||||
Box::pin(TestCodexHarness::with_builder(builder)).await
|
||||
}
|
||||
|
||||
pub async fn mount_apply_patch(
|
||||
@@ -87,7 +90,54 @@ fn apply_patch_responses(
|
||||
]
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let harness = apply_patch_harness().await?;
|
||||
let codex_linux_sandbox_exe = harness
|
||||
.test()
|
||||
.config
|
||||
.codex_linux_sandbox_exe
|
||||
.as_ref()
|
||||
.expect("linux test config should include codex-linux-sandbox helper");
|
||||
assert_eq!(
|
||||
codex_linux_sandbox_exe
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str()),
|
||||
Some(CODEX_LINUX_SANDBOX_ARG0),
|
||||
);
|
||||
|
||||
let patch = "*** Begin Patch\n*** Add File: helper-alias.txt\n+hello\n*** End Patch";
|
||||
let call_id = "apply-helper-alias";
|
||||
mount_apply_patch(
|
||||
&harness,
|
||||
call_id,
|
||||
patch,
|
||||
"done",
|
||||
ApplyPatchModelOutput::Function,
|
||||
)
|
||||
.await;
|
||||
|
||||
harness.submit("please apply helper alias patch").await?;
|
||||
|
||||
let out = harness
|
||||
.apply_patch_output(call_id, ApplyPatchModelOutput::Function)
|
||||
.await;
|
||||
assert_regex_match(
|
||||
r"(?s)^Exit code: 0.*Success\. Updated the following files:\nA helper-alias\.txt\n?$",
|
||||
&out,
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(harness.path("helper-alias.txt"))?,
|
||||
"hello\n"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -134,7 +184,7 @@ D delete.txt
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -161,7 +211,7 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -190,7 +240,7 @@ async fn apply_patch_cli_moves_file_to_new_directory(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -218,7 +268,7 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -244,7 +294,7 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -275,7 +325,7 @@ async fn apply_patch_cli_move_overwrites_existing_destination(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -338,7 +388,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -364,7 +414,7 @@ async fn apply_patch_cli_add_overwrites_existing_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -396,7 +446,7 @@ async fn apply_patch_cli_rejects_invalid_hunk_header(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -430,7 +480,7 @@ async fn apply_patch_cli_reports_missing_context(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -466,7 +516,7 @@ async fn apply_patch_cli_reports_missing_target_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -503,7 +553,7 @@ async fn apply_patch_cli_delete_missing_file_reports_error(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -528,7 +578,7 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -555,7 +605,7 @@ async fn apply_patch_cli_delete_directory_reports_verification_error(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -609,7 +659,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -664,7 +714,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -701,7 +751,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -739,7 +789,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -879,7 +929,7 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -964,7 +1014,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1042,7 +1092,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
|
||||
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
|
||||
async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch(
|
||||
@@ -1065,7 +1115,7 @@ async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -1088,7 +1138,7 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -1123,7 +1173,7 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -1184,7 +1234,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
@@ -1252,7 +1302,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1322,7 +1372,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1414,7 +1464,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[large_stack_test]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[test_case(ApplyPatchModelOutput::Freeform)]
|
||||
#[test_case(ApplyPatchModelOutput::Function)]
|
||||
#[test_case(ApplyPatchModelOutput::Shell)]
|
||||
|
||||
@@ -12,8 +12,8 @@ use codex_core::auth::RefreshTokenError;
|
||||
use codex_core::auth::load_auth_dot_json;
|
||||
use codex_core::auth::save_auth;
|
||||
use codex_core::error::RefreshTokenFailedReason;
|
||||
use codex_core::token_data::IdTokenInfo;
|
||||
use codex_core::token_data::TokenData;
|
||||
use codex_login::token_data::IdTokenInfo;
|
||||
use codex_login::token_data::TokenData;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -829,6 +829,9 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
.features
|
||||
.enabled(Feature::DefaultModeRequestUserInput),
|
||||
},
|
||||
Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
let NewThread { thread: codex, .. } = thread_manager
|
||||
.start_thread(config)
|
||||
|
||||
@@ -207,6 +207,7 @@ async fn run_code_mode_turn_with_rmcp(
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
config
|
||||
|
||||
@@ -237,6 +237,78 @@ elif mode == "exit_2":
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_post_tool_use_hook(
|
||||
home: &Path,
|
||||
matcher: Option<&str>,
|
||||
mode: &str,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
let script_path = home.join("post_tool_use_hook.py");
|
||||
let log_path = home.join("post_tool_use_hook_log.jsonl");
|
||||
let mode_json = serde_json::to_string(mode).context("serialize post tool use mode")?;
|
||||
let reason_json = serde_json::to_string(reason).context("serialize post tool use reason")?;
|
||||
let script = format!(
|
||||
r#"import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
log_path = Path(r"{log_path}")
|
||||
mode = {mode_json}
|
||||
reason = {reason_json}
|
||||
|
||||
payload = json.load(sys.stdin)
|
||||
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
|
||||
if mode == "context":
|
||||
print(json.dumps({{
|
||||
"hookSpecificOutput": {{
|
||||
"hookEventName": "PostToolUse",
|
||||
"additionalContext": reason
|
||||
}}
|
||||
}}))
|
||||
elif mode == "decision_block":
|
||||
print(json.dumps({{
|
||||
"decision": "block",
|
||||
"reason": reason
|
||||
}}))
|
||||
elif mode == "continue_false":
|
||||
print(json.dumps({{
|
||||
"continue": False,
|
||||
"stopReason": reason
|
||||
}}))
|
||||
elif mode == "exit_2":
|
||||
sys.stderr.write(reason + "\n")
|
||||
raise SystemExit(2)
|
||||
"#,
|
||||
log_path = log_path.display(),
|
||||
mode_json = mode_json,
|
||||
reason_json = reason_json,
|
||||
);
|
||||
|
||||
let mut group = serde_json::json!({
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": format!("python3 {}", script_path.display()),
|
||||
"statusMessage": "running post tool use hook",
|
||||
}]
|
||||
});
|
||||
if let Some(matcher) = matcher {
|
||||
group["matcher"] = Value::String(matcher.to_string());
|
||||
}
|
||||
|
||||
let hooks = serde_json::json!({
|
||||
"hooks": {
|
||||
"PostToolUse": [group]
|
||||
}
|
||||
});
|
||||
|
||||
fs::write(&script_path, script).context("write post tool use hook script")?;
|
||||
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> {
|
||||
let script_path = home.join("session_start_hook.py");
|
||||
let log_path = home.join("session_start_hook_log.jsonl");
|
||||
@@ -325,6 +397,15 @@ fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>>
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
fs::read_to_string(home.join("post_tool_use_hook_log.jsonl"))
|
||||
.context("read post tool use hook log")?
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str(line).context("parse post tool use hook log line"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_session_start_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
|
||||
fs::read_to_string(home.join("session_start_hook_log.jsonl"))
|
||||
.context("read session start hook log")?
|
||||
@@ -986,7 +1067,7 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
|
||||
.and_then(Value::as_str)
|
||||
.expect("shell command output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked by pre hook"),
|
||||
output.contains("Command blocked by PreToolUse hook: blocked by pre hook"),
|
||||
"blocked tool output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
@@ -1088,7 +1169,7 @@ async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
|
||||
.and_then(Value::as_str)
|
||||
.expect("local shell output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked local shell"),
|
||||
output.contains("Command blocked by PreToolUse hook: blocked local shell"),
|
||||
"blocked local shell output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
@@ -1183,7 +1264,7 @@ async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
|
||||
.and_then(Value::as_str)
|
||||
.expect("exec command output string");
|
||||
assert!(
|
||||
output.contains("Bash command blocked by hook: blocked exec command"),
|
||||
output.contains("Command blocked by PreToolUse hook: blocked exec command"),
|
||||
"blocked exec command output should surface the hook reason",
|
||||
);
|
||||
assert!(
|
||||
@@ -1275,3 +1356,458 @@ async fn pre_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_tool_use_records_additional_context_for_shell_command() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "posttooluse-shell-command";
|
||||
let command = "printf post-tool-output".to_string();
|
||||
let args = serde_json::json!({ "command": command });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "post hook context observed"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let post_context = "Remember the bash post-tool note.";
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_post_tool_use_hook(home, Some("^Bash$"), "context", post_context)
|
||||
{
|
||||
panic!("failed to write post tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("run the shell command with post hook")
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
requests[1]
|
||||
.message_input_texts("developer")
|
||||
.contains(&post_context.to_string()),
|
||||
"follow-up request should include post tool use additional context",
|
||||
);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("shell command output string");
|
||||
assert!(
|
||||
output.contains("post-tool-output"),
|
||||
"shell command output should still reach the model",
|
||||
);
|
||||
|
||||
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["hook_event_name"], "PostToolUse");
|
||||
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_response"],
|
||||
Value::String("post-tool-output".to_string())
|
||||
);
|
||||
let transcript_path = hook_inputs[0]["transcript_path"]
|
||||
.as_str()
|
||||
.expect("post tool use hook transcript_path");
|
||||
assert!(
|
||||
!transcript_path.is_empty(),
|
||||
"post tool use hook should receive a non-empty transcript_path",
|
||||
);
|
||||
assert!(
|
||||
Path::new(transcript_path).exists(),
|
||||
"post tool use hook transcript_path should be materialized on disk",
|
||||
);
|
||||
assert!(
|
||||
hook_inputs[0]["turn_id"]
|
||||
.as_str()
|
||||
.is_some_and(|turn_id| !turn_id.is_empty())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "posttooluse-shell-command-block";
|
||||
let command = "printf blocked-output".to_string();
|
||||
let args = serde_json::json!({ "command": command });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "post hook feedback observed"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let reason = "bash output looked sketchy";
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_post_tool_use_hook(home, Some("^Bash$"), "decision_block", reason)
|
||||
{
|
||||
panic!("failed to write post tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("run the shell command with blocking post hook")
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("shell command output string");
|
||||
assert_eq!(output, reason);
|
||||
|
||||
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_response"],
|
||||
Value::String("blocked-output".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_reason() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "posttooluse-shell-command-stop";
|
||||
let command = "printf stop-output".to_string();
|
||||
let args = serde_json::json!({ "command": command });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "post hook stop observed"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let stop_reason = "Execution halted by post-tool hook";
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_post_tool_use_hook(home, Some("^Bash$"), "continue_false", stop_reason)
|
||||
{
|
||||
panic!("failed to write post tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("run the shell command with stop-style post hook")
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("shell command output string");
|
||||
assert_eq!(output, stop_reason);
|
||||
|
||||
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_response"],
|
||||
Value::String("stop-output".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_tool_use_records_additional_context_for_local_shell() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "posttooluse-local-shell";
|
||||
let command = vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"printf local-post-tool-output".to_string(),
|
||||
];
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_local_shell_call(
|
||||
call_id,
|
||||
"completed",
|
||||
command.iter().map(String::as_str).collect(),
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "local shell post hook context observed"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let post_context = "Remember the local shell post-tool note.";
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_post_tool_use_hook(home, Some("^Bash$"), "context", post_context)
|
||||
{
|
||||
panic!("failed to write post tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("run the local shell command with post hook")
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
requests[1]
|
||||
.message_input_texts("developer")
|
||||
.contains(&post_context.to_string()),
|
||||
"follow-up request should include local shell post tool use additional context",
|
||||
);
|
||||
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_input"]["command"],
|
||||
codex_shell_command::parse_command::shlex_join(&command),
|
||||
);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_response"],
|
||||
Value::String("local-post-tool-output".to_string()),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedback() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "posttooluse-exec-command";
|
||||
let command = "printf post-hook-output".to_string();
|
||||
let args = serde_json::json!({ "cmd": command, "tty": false });
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"exec_command",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "post hook blocked the exec result"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_post_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked by post hook")
|
||||
{
|
||||
panic!("failed to write post tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("run the exec command with post hook")
|
||||
.await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("exec command output string");
|
||||
assert_eq!(output, "blocked by post hook");
|
||||
|
||||
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
|
||||
assert_eq!(hook_inputs.len(), 1);
|
||||
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
|
||||
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
|
||||
assert_eq!(
|
||||
hook_inputs[0]["tool_response"],
|
||||
Value::String("post-hook-output".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn post_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let call_id = "posttooluse-update-plan";
|
||||
let args = serde_json::json!({
|
||||
"plan": [{
|
||||
"step": "watch the tide",
|
||||
"status": "pending",
|
||||
}]
|
||||
});
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
core_test_support::responses::ev_function_call(
|
||||
call_id,
|
||||
"update_plan",
|
||||
&serde_json::to_string(&args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "plan updated"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) =
|
||||
write_post_tool_use_hook(home, None, "decision_block", "should not fire")
|
||||
{
|
||||
panic!("failed to write post tool use hook test fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("update the plan").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let output_item = requests[1].function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("update plan output string");
|
||||
assert!(
|
||||
!output.contains("should not fire"),
|
||||
"non-shell tool output should not be affected by PostToolUse",
|
||||
);
|
||||
|
||||
let hook_log_path = test.codex_home_path().join("post_tool_use_hook_log.jsonl");
|
||||
assert!(
|
||||
!hook_log_path.exists(),
|
||||
"non-shell tools should not trigger post tool use hooks",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user