Merge 95686245c8 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-05 15:21:06 -07:00
committed by GitHub
108 changed files with 7693 additions and 1905 deletions
+2
View File
@@ -52,10 +52,12 @@ jobs:
CODEX_VERSION=0.125.0
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298"
OUTPUT_DIR="${RUNNER_TEMP}"
# This reused workflow predates the standalone bwrap artifact.
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \
--workflow-url "$WORKFLOW_URL" \
--package codex \
--allow-missing-native-component bwrap \
--output-dir "$OUTPUT_DIR"
PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz"
echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT"
+25 -1
View File
@@ -255,6 +255,24 @@ jobs:
with:
target: ${{ matrix.target }}
- if: ${{ contains(matrix.target, 'linux') }}
name: Build bwrap and export digest
shell: bash
run: |
set -euo pipefail
target="${{ matrix.target }}"
cargo build --target "$target" --release --timings --bin bwrap
bwrap_path="target/${target}/release/bwrap"
if [[ ! -f "$bwrap_path" ]]; then
echo "bwrap binary ${bwrap_path} not found"
exit 1
fi
digest="$(sha256sum "$bwrap_path" | awk '{print $1}')"
echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV"
echo "Built bwrap ${bwrap_path} with sha256:${digest}"
- name: Cargo build
shell: bash
run: |
@@ -278,7 +296,7 @@ jobs:
with:
target: ${{ matrix.target }}
artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release
binaries: ${{ matrix.binaries }}
binaries: ${{ matrix.binaries }} bwrap
- if: ${{ runner.os == 'macOS' }}
name: MacOS code signing (binaries)
@@ -361,6 +379,12 @@ jobs:
fi
done
if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then
cp "target/${{ matrix.target }}/release/bwrap" "$dest/bwrap-${{ matrix.target }}"
cp "target/${{ matrix.target }}/release/bwrap.sigstore" \
"$dest/bwrap-${{ matrix.target }}.sigstore"
fi
if [[ "${{ matrix.build_dmg }}" == "true" ]]; then
cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg"
fi
+18 -2
View File
@@ -69,8 +69,8 @@ PACKAGE_EXPANSIONS: dict[str, list[str]] = {
PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
"codex": [],
"codex-linux-x64": ["codex", "rg"],
"codex-linux-arm64": ["codex", "rg"],
"codex-linux-x64": ["bwrap", "codex", "rg"],
"codex-linux-arm64": ["bwrap", "codex", "rg"],
"codex-darwin-x64": ["codex", "rg"],
"codex-darwin-arm64": ["codex", "rg"],
"codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"],
@@ -87,6 +87,7 @@ PACKAGE_TARGET_FILTERS: dict[str, str] = {
PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS)
COMPONENT_DEST_DIR: dict[str, str] = {
"bwrap": "codex-resources",
"codex": "codex",
"codex-responses-api-proxy": "codex-responses-api-proxy",
"codex-windows-sandbox-setup": "codex",
@@ -137,6 +138,16 @@ def parse_args() -> argparse.Namespace:
type=Path,
help="Directory containing pre-installed native binaries to bundle (vendor root).",
)
parser.add_argument(
"--allow-missing-native-component",
dest="allow_missing_native_components",
action="append",
default=[],
help=(
"Native component that may be absent from --vendor-src. Intended for CI "
"compatibility with older artifact workflows; releases should not use this."
),
)
return parser.parse_args()
@@ -177,6 +188,7 @@ def main() -> int:
staging_dir,
native_components,
target_filter={target_filter} if target_filter else None,
allow_missing_components=set(args.allow_missing_native_components),
)
if release_version:
@@ -365,12 +377,14 @@ def copy_native_binaries(
staging_dir: Path,
components: list[str],
target_filter: set[str] | None = None,
allow_missing_components: set[str] | None = None,
) -> None:
vendor_src = vendor_src.resolve()
if not vendor_src.exists():
raise RuntimeError(f"Vendor source directory not found: {vendor_src}")
components_set = {component for component in components if component in COMPONENT_DEST_DIR}
allow_missing_components = allow_missing_components or set()
if not components_set:
return
@@ -399,6 +413,8 @@ def copy_native_binaries(
src_component_dir = target_dir / dest_dir_name
if not src_component_dir.exists():
if component in allow_missing_components:
continue
raise RuntimeError(
f"Missing native component '{component}' in vendor source: {src_component_dir}"
)
+8 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Install Codex native binaries (Rust CLI plus ripgrep helpers)."""
"""Install Codex native binaries (Rust CLI, bwrap, and ripgrep helpers)."""
import argparse
from contextlib import contextmanager
@@ -42,8 +42,15 @@ class BinaryComponent:
WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target)
LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target)
BINARY_COMPONENTS = {
"bwrap": BinaryComponent(
artifact_prefix="bwrap",
dest_dir="codex-resources",
binary_basename="bwrap",
targets=LINUX_TARGETS,
),
"codex": BinaryComponent(
artifact_prefix="codex",
dest_dir="codex",
+10 -2
View File
@@ -2125,6 +2125,15 @@ dependencies = [
"serde_with",
]
[[package]]
name = "codex-bwrap"
version = "0.0.0"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "codex-chatgpt"
version = "0.0.0"
@@ -2902,7 +2911,6 @@ dependencies = [
name = "codex-linux-sandbox"
version = "0.0.0"
dependencies = [
"cc",
"clap",
"codex-core",
"codex-process-hardening",
@@ -2912,11 +2920,11 @@ dependencies = [
"globset",
"landlock",
"libc",
"pkg-config",
"pretty_assertions",
"seccompiler",
"serde",
"serde_json",
"sha2",
"tempfile",
"tokio",
"url",
+2
View File
@@ -5,6 +5,7 @@ members = [
"agent-graph-store",
"agent-identity",
"backend-client",
"bwrap",
"ansi-escape",
"async-utils",
"app-server",
@@ -137,6 +138,7 @@ codex-apply-patch = { path = "apply-patch" }
codex-arg0 = { path = "arg0" }
codex-async-utils = { path = "async-utils" }
codex-backend-client = { path = "backend-client" }
codex-bwrap = { path = "bwrap" }
codex-chatgpt = { path = "chatgpt" }
codex-cli = { path = "cli" }
codex-client = { path = "codex-client" }
+12 -2
View File
@@ -954,12 +954,22 @@ impl MessageProcessor {
}
ClientRequest::ThreadResume { params, .. } => {
self.thread_processor
.thread_resume(request_id.clone(), params)
.thread_resume(
request_id.clone(),
params,
app_server_client_name.clone(),
client_version.clone(),
)
.await
}
ClientRequest::ThreadFork { params, .. } => {
self.thread_processor
.thread_fork(request_id.clone(), params)
.thread_fork(
request_id.clone(),
params,
app_server_client_name.clone(),
client_version.clone(),
)
.await
}
ClientRequest::ThreadArchive { params, .. } => {
@@ -421,20 +421,34 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_resume_inner(request_id, params)
.await
.map(|()| None)
self.thread_resume_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
)
.await
.map(|()| None)
}
pub(crate) async fn thread_fork(
&self,
request_id: ConnectionRequestId,
params: ThreadForkParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_fork_inner(request_id, params)
.await
.map(|()| None)
self.thread_fork_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
)
.await
.map(|()| None)
}
pub(crate) async fn thread_archive(
@@ -682,8 +696,16 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny(
app_server_client_name.as_deref(),
app_server_client_version.as_deref(),
);
thread
.set_app_server_client_info(app_server_client_name, app_server_client_version)
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
mcp_elicitations_auto_deny,
)
.await
.map_err(|err| internal_error(format!("failed to set app server client info: {err}")))
}
@@ -2304,6 +2326,8 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread_id) = ThreadId::from_string(&params.thread_id)
&& self
@@ -2353,7 +2377,15 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
match self.resume_running_thread(&request_id, &params).await {
match self
.resume_running_thread(
&request_id,
&params,
app_server_client_name.clone(),
app_server_client_version.clone(),
)
.await
{
Ok(true) => return Ok(()),
Ok(false) => {}
Err(error) => {
@@ -2505,6 +2537,16 @@ impl ThreadRequestProcessor {
session_configured,
..
}) => {
if let Err(err) = Self::set_app_server_client_info(
codex_thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await
{
self.outgoing.send_error(request_id, err).await;
return Ok(());
}
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
let Some(rollout_path) = rollout_path else {
let error =
@@ -2643,6 +2685,8 @@ impl ThreadRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: &ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<bool, JSONRPCErrorError> {
let running_thread = if params.history.is_some() {
if let Ok(existing_thread_id) = ThreadId::from_string(&params.thread_id)
@@ -2724,6 +2768,12 @@ impl ThreadRequestProcessor {
thread_state.clone(),
)
.await?;
Self::set_app_server_client_info(
existing_thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await?;
let config_snapshot = existing_thread.config_snapshot().await;
let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot);
@@ -3007,6 +3057,8 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadForkParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
let ThreadForkParams {
thread_id,
@@ -3167,6 +3219,13 @@ impl ThreadRequestProcessor {
err => internal_error(format!("error forking thread: {err}")),
})?;
Self::set_app_server_client_info(
forked_thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await?;
// Auto-attach a conversation listener when forking a thread.
log_listener_attach_result(
self.ensure_conversation_listener(
@@ -3425,6 +3484,17 @@ impl ThreadRequestProcessor {
}
}
fn xcode_26_4_mcp_elicitations_auto_deny(
client_name: Option<&str>,
client_version: Option<&str>,
) -> bool {
// Xcode 26.4 shipped before app-server MCP elicitation requests were
// client-visible. Keep elicitations auto-denied for that client line.
// TODO: Remove this compatibility hack once Xcode 26.4 ages out.
client_name == Some("Xcode")
&& client_version.is_some_and(|version| version.starts_with("26.4"))
}
const THREAD_TURNS_DEFAULT_LIMIT: usize = 25;
const THREAD_TURNS_MAX_LIMIT: usize = 100;
@@ -553,8 +553,16 @@ impl TurnRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny(
app_server_client_name.as_deref(),
app_server_client_version.as_deref(),
);
thread
.set_app_server_client_info(app_server_client_name, app_server_client_version)
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
mcp_elicitations_auto_deny,
)
.await
.map_err(|err| internal_error(format!("failed to set app server client info: {err}")))
}
@@ -1104,3 +1112,14 @@ impl TurnRequestProcessor {
.await
}
}
fn xcode_26_4_mcp_elicitations_auto_deny(
client_name: Option<&str>,
client_version: Option<&str>,
) -> bool {
// Xcode 26.4 shipped before app-server MCP elicitation requests were
// client-visible. Keep elicitations auto-denied for that client line.
// TODO: Remove this compatibility hack once Xcode 26.4 ages out.
client_name == Some("Xcode")
&& client_version.is_some_and(|version| version.starts_with("26.4"))
}
+35
View File
@@ -0,0 +1,35 @@
load("@rules_cc//cc:defs.bzl", "cc_library")
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "bwrap",
crate_name = "codex_bwrap",
# Bazel wires vendored bubblewrap + libcap via :bwrap-ffi below and sets
# bwrap_available explicitly, so we skip Cargo's build.rs in Bazel builds.
build_script_enabled = False,
deps_extra = select({
"@platforms//os:linux": [":bwrap-ffi"],
"//conditions:default": [],
}),
rustc_flags_extra = select({
"@platforms//os:linux": ["--cfg=bwrap_available"],
"//conditions:default": [],
}),
)
cc_library(
name = "bwrap-ffi",
srcs = ["//codex-rs/vendor:bubblewrap_c_sources"],
hdrs = [
"config.h",
"//codex-rs/vendor:bubblewrap_headers",
],
copts = [
"-D_GNU_SOURCE",
"-Dmain=bwrap_main",
],
includes = ["."],
deps = ["@libcap//:libcap"],
target_compatible_with = ["@platforms//os:linux"],
visibility = ["//visibility:private"],
)
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "codex-bwrap"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "bwrap"
path = "src/main.rs"
[lints]
workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
libc = { workspace = true }
[build-dependencies]
cc = "1"
pkg-config = "0.3"
+99
View File
@@ -0,0 +1,99 @@
use std::env;
use std::path::Path;
use std::path::PathBuf;
fn main() {
println!("cargo:rustc-check-cfg=cfg(bwrap_available)");
println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR");
println!("cargo:rerun-if-env-changed=CODEX_SKIP_BWRAP_BUILD");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
let vendor_dir = manifest_dir.join("../vendor/bubblewrap");
for source in ["bubblewrap.c", "bind-mount.c", "network.c", "utils.c"] {
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join(source).display()
);
}
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "linux" || env::var_os("CODEX_SKIP_BWRAP_BUILD").is_some() {
return;
}
if let Err(err) = try_build_bwrap() {
panic!("failed to compile bubblewrap for Linux target: {err}");
}
}
fn try_build_bwrap() -> Result<(), String> {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?);
let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?);
let src_dir = resolve_bwrap_source_dir(&manifest_dir)?;
let libcap = pkg_config::Config::new()
.probe("libcap")
.map_err(|err| format!("libcap not available via pkg-config: {err}"))?;
let config_h = out_dir.join("config.h");
std::fs::write(
&config_h,
r#"#pragma once
#define PACKAGE_STRING "bubblewrap built for Codex"
"#,
)
.map_err(|err| format!("failed to write {}: {err}", config_h.display()))?;
let mut build = cc::Build::new();
build
.file(src_dir.join("bubblewrap.c"))
.file(src_dir.join("bind-mount.c"))
.file(src_dir.join("network.c"))
.file(src_dir.join("utils.c"))
.include(&out_dir)
.include(&src_dir)
.define("_GNU_SOURCE", None)
// Rename `main` so the Rust wrapper can expose the Cargo-built binary.
.define("main", Some("bwrap_main"));
for include_path in libcap.include_paths {
// Use -idirafter so target sysroot headers win (musl cross builds),
// while still allowing libcap headers from the host toolchain.
build.flag(format!("-idirafter{}", include_path.display()));
}
build.compile("standalone_bwrap");
println!("cargo:rustc-cfg=bwrap_available");
Ok(())
}
/// Resolve the bubblewrap source directory used for build-time compilation.
///
/// Priority:
/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout.
/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`.
fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result<PathBuf, String> {
if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") {
let src_dir = PathBuf::from(path);
if src_dir.exists() {
return Ok(src_dir);
}
return Err(format!(
"CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}",
src_dir.display()
));
}
let vendor_dir = manifest_dir.join("../vendor/bubblewrap");
if vendor_dir.exists() {
return Ok(vendor_dir);
}
Err(format!(
"expected vendored bubblewrap at {}, but it was not found.\n\
Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.",
vendor_dir.display()
))
}
+1
View File
@@ -0,0 +1 @@
#define PACKAGE_STRING "bubblewrap built for Codex"
+43
View File
@@ -0,0 +1,43 @@
#[cfg(all(target_os = "linux", bwrap_available))]
fn main() {
use std::ffi::CString;
use std::os::raw::c_char;
use std::os::unix::ffi::OsStrExt;
unsafe extern "C" {
fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int;
}
let cstrings = std::env::args_os()
.map(|arg| {
CString::new(arg.as_os_str().as_bytes())
.unwrap_or_else(|err| panic!("failed to convert argv to CString: {err}"))
})
.collect::<Vec<_>>();
let mut argv_ptrs = cstrings
.iter()
.map(CString::as_ptr)
.collect::<Vec<*const c_char>>();
argv_ptrs.push(std::ptr::null());
// SAFETY: We provide a null-terminated argv vector whose pointers remain
// valid for the duration of the call.
let exit_code = unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) };
std::process::exit(exit_code);
}
#[cfg(all(target_os = "linux", not(bwrap_available)))]
fn main() {
panic!(
r#"bubblewrap is not available in this build.
Notes:
- ensure the target OS is Linux
- libcap headers must be available via pkg-config
- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"#
);
}
#[cfg(not(target_os = "linux"))]
fn main() {
panic!("bwrap is only supported on Linux");
}
@@ -128,6 +128,14 @@ impl McpConnectionManager {
}
}
pub fn elicitations_auto_deny(&self) -> bool {
self.elicitation_requests.auto_deny()
}
pub fn set_elicitations_auto_deny(&self, auto_deny: bool) {
self.elicitation_requests.set_auto_deny(auto_deny);
}
#[allow(clippy::new_ret_no_self, clippy::too_many_arguments)]
pub async fn new(
mcp_servers: &HashMap<String, McpServerConfig>,
+29
View File
@@ -36,6 +36,7 @@ pub(crate) struct ElicitationRequestManager {
requests: Arc<Mutex<ResponderMap>>,
pub(crate) approval_policy: Arc<StdMutex<AskForApproval>>,
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
auto_deny: Arc<StdMutex<bool>>,
}
impl ElicitationRequestManager {
@@ -47,6 +48,20 @@ impl ElicitationRequestManager {
requests: Arc::new(Mutex::new(HashMap::new())),
approval_policy: Arc::new(StdMutex::new(approval_policy)),
permission_profile: Arc::new(StdMutex::new(permission_profile)),
auto_deny: Arc::new(StdMutex::new(false)),
}
}
pub(crate) fn auto_deny(&self) -> bool {
self.auto_deny
.lock()
.map(|auto_deny| *auto_deny)
.unwrap_or(false)
}
pub(crate) fn set_auto_deny(&self, auto_deny: bool) {
if let Ok(mut current) = self.auto_deny.lock() {
*current = auto_deny;
}
}
@@ -73,13 +88,27 @@ impl ElicitationRequestManager {
let elicitation_requests = self.requests.clone();
let approval_policy = self.approval_policy.clone();
let permission_profile = self.permission_profile.clone();
let auto_deny = self.auto_deny.clone();
Box::new(move |id, elicitation| {
let elicitation_requests = elicitation_requests.clone();
let tx_event = tx_event.clone();
let server_name = server_name.clone();
let approval_policy = approval_policy.clone();
let permission_profile = permission_profile.clone();
let auto_deny = auto_deny.clone();
async move {
let auto_deny = auto_deny
.lock()
.map(|auto_deny| *auto_deny)
.unwrap_or(false);
if auto_deny {
return Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
});
}
let approval_policy = approval_policy
.lock()
.map(|policy| *policy)
+14
View File
@@ -7,6 +7,7 @@ use crate::config_toml::ToolsToml;
use crate::types::AnalyticsConfigToml;
use crate::types::ApprovalsReviewer;
use crate::types::Personality;
use crate::types::SessionPickerViewMode;
use crate::types::WindowsToml;
use codex_features::FeaturesToml;
use codex_protocol::config_types::ReasoningSummary;
@@ -63,6 +64,9 @@ pub struct ConfigProfile {
pub tools: Option<ToolsToml>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<AnalyticsConfigToml>,
/// TUI settings scoped to this profile.
#[serde(default)]
pub tui: Option<ProfileTui>,
#[serde(default)]
pub windows: Option<WindowsToml>,
/// Optional feature toggles scoped to this profile.
@@ -73,6 +77,16 @@ pub struct ConfigProfile {
pub oss_provider: Option<String>,
}
/// TUI settings supported inside a named profile.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct ProfileTui {
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
pub session_picker_view: Option<SessionPickerViewMode>,
}
impl From<ConfigProfile> for codex_app_server_protocol::Profile {
fn from(config_profile: ConfigProfile) -> Self {
Self {
+28
View File
@@ -57,6 +57,30 @@ const fn default_enabled() -> bool {
true
}
/// Preferred layout for the resume/fork session picker.
#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum SessionPickerViewMode {
Comfortable,
#[default]
Dense,
}
impl SessionPickerViewMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Comfortable => "comfortable",
Self::Dense => "dense",
}
}
}
impl fmt::Display for SessionPickerViewMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Determine where Codex should store CLI auth credentials.
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
@@ -661,6 +685,10 @@ pub struct Tui {
#[serde(default)]
pub theme: Option<String>,
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
pub session_picker_view: Option<SessionPickerViewMode>,
/// Keybinding overrides for the TUI.
///
/// This supports rebinding selected actions globally and by context.
+1
View File
@@ -18,6 +18,7 @@ pub use codex_config::types::ModelAvailabilityNuxConfig;
pub use codex_config::types::Notice;
pub use codex_config::types::OAuthCredentialsStoreMode;
pub use codex_config::types::OtelConfig;
pub use codex_config::types::SessionPickerViewMode;
pub use codex_config::types::ToolSuggestConfig;
pub use codex_config::types::TuiKeymap;
pub use codex_config::types::TuiNotificationSettings;
+8 -8
View File
@@ -39,14 +39,14 @@ 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. Codex also surfaces
a startup warning when bubblewrap cannot create user namespaces. WSL2 uses the
normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing
because it cannot create the required user namespaces, so Codex rejects
sandboxed shell commands that would enter the bubblewrap path before invoking
`bwrap`.
`bwrap` is missing, it falls back to the bundled `codex-resources/bwrap`
binary shipped with Codex and Codex surfaces a startup warning through its
normal notification path instead of printing directly from the sandbox helper.
Codex also surfaces a startup warning when bubblewrap cannot create user
namespaces. WSL2 uses the normal Linux bubblewrap path. WSL1 is not supported
for bubblewrap sandboxing because it cannot create the required user
namespaces, so Codex rejects sandboxed shell commands that would enter the
bubblewrap path before invoking `bwrap`.
### Windows
+42
View File
@@ -674,6 +674,15 @@
"tools_view_image": {
"type": "boolean"
},
"tui": {
"allOf": [
{
"$ref": "#/definitions/ProfileTui"
}
],
"default": null,
"description": "TUI settings scoped to this profile."
},
"web_search": {
"$ref": "#/definitions/WebSearchMode"
},
@@ -1876,6 +1885,22 @@
},
"type": "object"
},
"ProfileTui": {
"additionalProperties": false,
"description": "TUI settings supported inside a named profile.",
"properties": {
"session_picker_view": {
"allOf": [
{
"$ref": "#/definitions/SessionPickerViewMode"
}
],
"default": null,
"description": "Preferred layout for resume/fork session picker results."
}
},
"type": "object"
},
"ProjectConfig": {
"additionalProperties": false,
"properties": {
@@ -2163,6 +2188,14 @@
],
"type": "string"
},
"SessionPickerViewMode": {
"description": "Preferred layout for the resume/fork session picker.",
"enum": [
"comfortable",
"dense"
],
"type": "string"
},
"ShellEnvironmentPolicyInherit": {
"oneOf": [
{
@@ -2567,6 +2600,15 @@
"description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.",
"type": "boolean"
},
"session_picker_view": {
"allOf": [
{
"$ref": "#/definitions/SessionPickerViewMode"
}
],
"default": null,
"description": "Preferred layout for resume/fork session picker results."
},
"show_tooltips": {
"default": true,
"description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.",
+6 -1
View File
@@ -227,9 +227,14 @@ impl CodexThread {
&self,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
mcp_elicitations_auto_deny: bool,
) -> ConstraintResult<()> {
self.codex
.set_app_server_client_info(app_server_client_name, app_server_client_version)
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
mcp_elicitations_auto_deny,
)
.await
}
+104
View File
@@ -44,6 +44,7 @@ use codex_config::types::NotificationCondition;
use codex_config::types::NotificationMethod;
use codex_config::types::Notifications;
use codex_config::types::SandboxWorkspaceWrite;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverableType;
@@ -563,6 +564,7 @@ fn config_toml_deserializes_model_availability_nux() {
status_line_use_colors: true,
terminal_title: None,
theme: None,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
@@ -2156,6 +2158,31 @@ fn tui_theme_defaults_to_none() {
assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None);
}
#[test]
fn tui_session_picker_view_deserializes_from_toml() {
let cfg = r#"
[tui]
session_picker_view = "dense"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.session_picker_view),
Some(SessionPickerViewMode::Dense),
);
}
#[test]
fn tui_session_picker_view_defaults_to_none() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.session_picker_view),
None,
);
}
#[test]
fn tui_config_missing_notifications_field_defaults_to_enabled() {
let cfg = r#"
@@ -2179,6 +2206,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
status_line_use_colors: true,
terminal_title: None,
theme: None,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow_max_rows: None,
@@ -2244,6 +2272,78 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides()
);
}
#[test]
fn profile_tui_rejects_unsupported_settings() {
let err = toml::from_str::<ConfigToml>(
r#"profile = "work"
[profiles.work.tui]
theme = "dark"
"#,
)
.expect_err("profile TUI config should only accept supported fields");
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("theme"));
}
#[tokio::test]
async fn runtime_config_resolves_session_picker_view_default_and_override() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load default config");
assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
session_picker_view: Some(SessionPickerViewMode::Comfortable),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load root override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
let cfg_toml = toml::from_str::<ConfigToml>(
r#"profile = "work"
[tui]
session_picker_view = "dense"
[profiles.work.tui]
session_picker_view = "comfortable"
"#,
)
.expect("parse profile scoped tui config");
let cfg = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load profile override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
}
#[tokio::test]
async fn test_sandbox_config_parsing() {
let sandbox_full_access = r#"
@@ -6514,6 +6614,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -6718,6 +6819,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
@@ -6876,6 +6978,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
@@ -7019,6 +7122,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
+28
View File
@@ -3,6 +3,7 @@ use crate::path_utils::write_atomically;
use anyhow::Context;
use codex_config::CONFIG_TOML_FILE;
use codex_config::types::McpServerConfig;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSuggestDisabledTool;
use codex_features::FEATURES;
use codex_protocol::config_types::Personality;
@@ -91,6 +92,14 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
}
}
/// Produces a config edit that sets `[tui].session_picker_view = "<mode>"`.
pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "session_picker_view".to_string()],
value: value(mode.to_string()),
}
}
/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list.
///
/// The array is written even when it is empty so "hide the status line" stays
@@ -1316,6 +1325,25 @@ impl ConfigEditsBuilder {
self
}
pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self {
let segments = if let Some(profile) = self.profile.as_ref() {
vec![
"profiles".to_string(),
profile.clone(),
"tui".to_string(),
"session_picker_view".to_string(),
]
} else {
vec!["tui".to_string(), "session_picker_view".to_string()]
};
self.edits.push(ConfigEdit::SetPath {
segments,
value: value(mode.to_string()),
});
self
}
pub fn with_edits<I>(mut self, edits: I) -> Self
where
I: IntoIterator<Item = ConfigEdit>,
+36
View File
@@ -2,6 +2,7 @@ use super::*;
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::SessionPickerViewMode;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
#[cfg(unix)]
@@ -48,6 +49,41 @@ fn builder_with_edits_applies_custom_paths() {
assert_eq!(contents, "enabled = true\n");
}
#[test]
fn session_picker_view_edit_writes_root_tui_setting() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_edits([session_picker_view_edit(SessionPickerViewMode::Dense)])
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[tui]
session_picker_view = "dense"
"#;
assert_eq!(contents, expected);
}
#[test]
fn session_picker_view_builder_respects_active_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_profile(Some("work"))
.set_session_picker_view(SessionPickerViewMode::Dense)
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[profiles.work.tui]
session_picker_view = "dense"
"#;
assert_eq!(contents, expected);
}
#[test]
fn keymap_binding_edit_writes_root_action_binding() {
let tmp = tempdir().expect("tmpdir");
+10
View File
@@ -49,6 +49,7 @@ use codex_config::types::OAuthCredentialsStoreMode;
use codex_config::types::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverable;
@@ -546,6 +547,9 @@ pub struct Config {
/// Syntax highlighting theme override (kebab-case name).
pub tui_theme: Option<String>,
/// Preferred layout for resume/fork session picker results.
pub tui_session_picker_view: SessionPickerViewMode,
/// Terminal resize-reflow tuning knobs.
pub terminal_resize_reflow: TerminalResizeReflowConfig,
@@ -3162,6 +3166,12 @@ impl Config {
.unwrap_or(true),
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
tui_session_picker_view: config_profile
.tui
.as_ref()
.and_then(|t| t.session_picker_view)
.or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view))
.unwrap_or_default(),
terminal_resize_reflow,
tui_keymap: cfg
.tui
+4
View File
@@ -254,6 +254,10 @@ impl Session {
auth.as_ref(),
)
.await;
{
let current_manager = self.services.mcp_connection_manager.read().await;
refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny());
}
{
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
if guard.is_cancelled() {
+5 -1
View File
@@ -759,6 +759,7 @@ impl Codex {
&self,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
mcp_elicitations_auto_deny: bool,
) -> ConstraintResult<()> {
self.session
.update_settings(SessionSettingsUpdate {
@@ -766,7 +767,10 @@ impl Codex {
app_server_client_version,
..Default::default()
})
.await
.await?;
let mcp_connection_manager = self.session.services.mcp_connection_manager.read().await;
mcp_connection_manager.set_elicitations_auto_deny(mcp_elicitations_auto_deny);
Ok(())
}
pub(crate) async fn agent_status(&self) -> AgentStatus {
+13 -10
View File
@@ -66,9 +66,10 @@ use crate::tasks::execute_user_shell_command;
use crate::tools::ToolRouter;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::GoalHandler;
use crate::tools::handlers::CreateGoalHandler;
use crate::tools::handlers::ExecCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::UpdateGoalHandler;
use crate::tools::registry::ToolHandler;
use crate::tools::router::ToolCallSource;
use crate::turn_diff_tracker::TurnDiffTracker;
@@ -8269,7 +8270,7 @@ async fn sample_rollout(
async fn create_goal_tool_rejects_existing_goal() {
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
let handler = CreateGoalHandler;
handler
.handle(ToolInvocation {
@@ -8331,9 +8332,10 @@ async fn create_goal_tool_rejects_existing_goal() {
async fn update_goal_tool_rejects_pausing_goal() {
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
let create_handler = CreateGoalHandler;
let update_handler = UpdateGoalHandler;
handler
create_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
@@ -8353,7 +8355,7 @@ async fn update_goal_tool_rejects_pausing_goal() {
.await
.expect("initial create_goal should succeed");
let response = handler
let response = update_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
@@ -8391,9 +8393,10 @@ async fn update_goal_tool_rejects_pausing_goal() {
async fn update_goal_tool_marks_goal_complete() {
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
let create_handler = CreateGoalHandler;
let update_handler = UpdateGoalHandler;
handler
create_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
@@ -8413,7 +8416,7 @@ async fn update_goal_tool_marks_goal_complete() {
.await
.expect("initial create_goal should succeed");
handler
update_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
@@ -8570,7 +8573,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request()
let turn_context = Arc::new(turn_context_raw);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = UnifiedExecHandler;
let handler = ExecCommandHandler;
let resp = handler
.handle(ToolInvocation {
session: Arc::clone(&session),
@@ -498,7 +498,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
let turn_context = Arc::new(turn_context_raw);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = UnifiedExecHandler;
let handler = ExecCommandHandler;
let resp = handler
.handle(ToolInvocation {
session: Arc::clone(&session),
@@ -4,6 +4,7 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::ToolName;
use super::ExecContext;
use super::PUBLIC_TOOL_NAME;
@@ -78,6 +79,10 @@ impl CodeModeExecuteHandler {
impl ToolHandler for CodeModeExecuteHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain(PUBLIC_TOOL_NAME)
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -6,6 +6,7 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::ToolName;
use super::DEFAULT_WAIT_YIELD_TIME_MS;
use super::ExecContext;
@@ -41,6 +42,10 @@ where
impl ToolHandler for CodeModeWaitHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain(WAIT_TOOL_NAME)
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
+42 -10
View File
@@ -19,6 +19,7 @@ use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
@@ -35,7 +36,8 @@ use tokio::time::Instant;
use tokio::time::timeout;
use uuid::Uuid;
pub struct BatchJobHandler;
pub struct SpawnAgentsOnCsvHandler;
pub struct ReportAgentJobResultHandler;
const DEFAULT_AGENT_JOB_CONCURRENCY: usize = 16;
const MAX_AGENT_JOB_CONCURRENCY: usize = 64;
@@ -99,9 +101,13 @@ struct ActiveJobItem {
status_rx: Option<Receiver<AgentStatus>>,
}
impl ToolHandler for BatchJobHandler {
impl ToolHandler for SpawnAgentsOnCsvHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("spawn_agents_on_csv")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -114,7 +120,6 @@ impl ToolHandler for BatchJobHandler {
let ToolInvocation {
session,
turn,
tool_name,
payload,
..
} = invocation;
@@ -128,13 +133,40 @@ impl ToolHandler for BatchJobHandler {
}
};
match tool_name.name.as_str() {
"spawn_agents_on_csv" => spawn_agents_on_csv::handle(session, turn, arguments).await,
"report_agent_job_result" => report_agent_job_result::handle(session, arguments).await,
other => Err(FunctionCallError::RespondToModel(format!(
"unsupported agent job tool {other}"
))),
}
spawn_agents_on_csv::handle(session, turn, arguments).await
}
}
impl ToolHandler for ReportAgentJobResultHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("report_agent_job_result")
}
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, payload, ..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"report_agent_job_result handler received unsupported payload".to_string(),
));
}
};
report_agent_job_result::handle(session, arguments).await
}
}
@@ -47,6 +47,7 @@ use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use codex_tools::ApplyPatchToolArgs;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500);
@@ -292,6 +293,10 @@ async fn effective_patch_permissions(
impl ToolHandler for ApplyPatchHandler {
type Output = ApplyPatchToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("apply_patch")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
+26 -9
View File
@@ -19,11 +19,23 @@ use std::time::Instant;
use tokio::sync::oneshot;
use tracing::warn;
pub struct DynamicToolHandler;
pub struct DynamicToolHandler {
tool_name: ToolName,
}
impl DynamicToolHandler {
pub fn new(tool_name: ToolName) -> Self {
Self { tool_name }
}
}
impl ToolHandler for DynamicToolHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
self.tool_name.clone()
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -37,7 +49,6 @@ impl ToolHandler for DynamicToolHandler {
session,
turn,
call_id,
tool_name,
payload,
..
} = invocation;
@@ -52,13 +63,19 @@ impl ToolHandler for DynamicToolHandler {
};
let args: Value = parse_arguments(&arguments)?;
let response = request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name, args)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"dynamic tool call was cancelled before receiving a response".to_string(),
)
})?;
let response = request_dynamic_tool(
&session,
turn.as_ref(),
call_id,
self.tool_name.clone(),
args,
)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"dynamic tool call was cancelled before receiving a response".to_string(),
)
})?;
let DynamicToolResponse {
content_items,
+118 -83
View File
@@ -8,8 +8,6 @@ use crate::function_tool::FunctionCallError;
use crate::goals::CreateGoalRequest;
use crate::goals::GoalRuntimeEvent;
use crate::goals::SetGoalRequest;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -20,13 +18,15 @@ use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_tools::CREATE_GOAL_TOOL_NAME;
use codex_tools::GET_GOAL_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::UPDATE_GOAL_TOOL_NAME;
use serde::Deserialize;
use serde::Serialize;
use std::fmt::Write as _;
use std::sync::Arc;
pub struct GoalHandler;
pub struct GetGoalHandler;
pub struct CreateGoalHandler;
pub struct UpdateGoalHandler;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -76,9 +76,44 @@ impl GoalToolResponse {
}
}
impl ToolHandler for GoalHandler {
impl ToolHandler for GetGoalHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain(GET_GOAL_TOOL_NAME)
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session, payload, ..
} = invocation;
match payload {
ToolPayload::Function { .. } => {
let goal = session
.get_thread_goal()
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
goal_response(goal, CompletionBudgetReport::Omit)
}
_ => Err(FunctionCallError::RespondToModel(
"get_goal handler received unsupported payload".to_string(),
)),
}
}
}
impl ToolHandler for CreateGoalHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain(CREATE_GOAL_TOOL_NAME)
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -88,7 +123,6 @@ impl ToolHandler for GoalHandler {
session,
turn,
payload,
tool_name,
..
} = invocation;
@@ -101,88 +135,89 @@ impl ToolHandler for GoalHandler {
}
};
match tool_name.name.as_str() {
GET_GOAL_TOOL_NAME => handle_get_goal(session.as_ref()).await,
CREATE_GOAL_TOOL_NAME => {
handle_create_goal(session.as_ref(), turn.as_ref(), &arguments).await
let args: CreateGoalArgs = parse_arguments(&arguments)?;
let goal = session
.create_thread_goal(
turn.as_ref(),
CreateGoalRequest {
objective: args.objective,
token_budget: args.token_budget,
},
)
.await
.map_err(|err| {
if err
.chain()
.any(|cause| cause.to_string().contains("already has a goal"))
{
FunctionCallError::RespondToModel(
"cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete"
.to_string(),
)
} else {
FunctionCallError::RespondToModel(format_goal_error(err))
}
})?;
goal_response(Some(goal), CompletionBudgetReport::Omit)
}
}
impl ToolHandler for UpdateGoalHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain(UPDATE_GOAL_TOOL_NAME)
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"update_goal handler received unsupported payload".to_string(),
));
}
UPDATE_GOAL_TOOL_NAME => handle_update_goal(&session, turn.as_ref(), &arguments).await,
other => Err(FunctionCallError::Fatal(format!(
"goal handler received unsupported tool: {other}"
))),
};
let args: UpdateGoalArgs = parse_arguments(&arguments)?;
if args.status != ThreadGoalStatus::Complete {
return Err(FunctionCallError::RespondToModel(
"update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system"
.to_string(),
));
}
session
.goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal {
turn_context: turn.as_ref(),
})
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
let goal = session
.set_thread_goal(
turn.as_ref(),
SetGoalRequest {
objective: None,
status: Some(ThreadGoalStatus::Complete),
token_budget: None,
},
)
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
goal_response(Some(goal), CompletionBudgetReport::Include)
}
}
async fn handle_get_goal(session: &Session) -> Result<FunctionToolOutput, FunctionCallError> {
let goal = session
.get_thread_goal()
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
goal_response(goal, CompletionBudgetReport::Omit)
}
async fn handle_create_goal(
session: &Session,
turn_context: &TurnContext,
arguments: &str,
) -> Result<FunctionToolOutput, FunctionCallError> {
let args: CreateGoalArgs = parse_arguments(arguments)?;
let goal = session
.create_thread_goal(
turn_context,
CreateGoalRequest {
objective: args.objective,
token_budget: args.token_budget,
},
)
.await
.map_err(|err| {
if err
.chain()
.any(|cause| cause.to_string().contains("already has a goal"))
{
FunctionCallError::RespondToModel(
"cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete"
.to_string(),
)
} else {
FunctionCallError::RespondToModel(format_goal_error(err))
}
})?;
goal_response(Some(goal), CompletionBudgetReport::Omit)
}
async fn handle_update_goal(
session: &Arc<Session>,
turn_context: &TurnContext,
arguments: &str,
) -> Result<FunctionToolOutput, FunctionCallError> {
let args: UpdateGoalArgs = parse_arguments(arguments)?;
if args.status != ThreadGoalStatus::Complete {
return Err(FunctionCallError::RespondToModel(
"update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system"
.to_string(),
));
}
session
.goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { turn_context })
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
let goal = session
.set_thread_goal(
turn_context,
SetGoalRequest {
objective: None,
status: Some(ThreadGoalStatus::Complete),
token_budget: None,
},
)
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
goal_response(Some(goal), CompletionBudgetReport::Include)
}
fn format_goal_error(err: anyhow::Error) -> String {
let mut message = err.to_string();
for cause in err.chain().skip(1) {
+28 -7
View File
@@ -13,12 +13,26 @@ use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::ToolName;
use serde_json::Value;
pub struct McpHandler;
pub struct McpHandler {
tool_name: ToolName,
}
impl McpHandler {
pub fn new(tool_name: ToolName) -> Self {
Self { tool_name }
}
}
impl ToolHandler for McpHandler {
type Output = McpToolOutput;
fn tool_name(&self) -> ToolName {
self.tool_name.clone()
}
fn kind(&self) -> ToolKind {
ToolKind::Mcp
}
@@ -29,7 +43,7 @@ impl ToolHandler for McpHandler {
};
Some(PreToolUsePayload {
tool_name: HookToolName::new(invocation.tool_name.display()),
tool_name: HookToolName::new(self.tool_name.display()),
tool_input: mcp_hook_tool_input(raw_arguments),
})
}
@@ -46,7 +60,7 @@ impl ToolHandler for McpHandler {
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::new(invocation.tool_name.display()),
tool_name: HookToolName::new(self.tool_name.display()),
tool_use_id: invocation.call_id.clone(),
tool_input: result.tool_input.clone(),
tool_response,
@@ -58,7 +72,6 @@ impl ToolHandler for McpHandler {
session,
turn,
call_id,
tool_name: model_tool_name,
payload,
..
} = invocation;
@@ -86,7 +99,7 @@ impl ToolHandler for McpHandler {
call_id.clone(),
server,
tool,
model_tool_name.display(),
self.tool_name.display(),
arguments_str,
)
.await;
@@ -134,9 +147,13 @@ mod tests {
.to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(codex_tools::ToolName::namespaced(
"mcp__memory__",
"create_entities",
));
assert_eq!(
McpHandler.pre_tool_use_payload(&ToolInvocation {
handler.pre_tool_use_payload(&ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
@@ -185,6 +202,10 @@ mod tests {
truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(codex_tools::ToolName::namespaced(
"mcp__filesystem__",
"read_file",
));
let invocation = ToolInvocation {
session: session.into(),
turn: turn.into(),
@@ -196,7 +217,7 @@ mod tests {
payload,
};
assert_eq!(
McpHandler.post_tool_use_payload(&invocation, &output),
handler.post_tool_use_payload(&invocation, &output),
Some(PostToolUsePayload {
tool_name: HookToolName::new("mcp__filesystem__read_file"),
tool_use_id: "call-mcp-post".to_string(),
+337 -322
View File
@@ -30,8 +30,11 @@ use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::protocol::McpInvocation;
use codex_tools::ToolName;
pub struct McpResourceHandler;
pub struct ListMcpResourcesHandler;
pub struct ListMcpResourceTemplatesHandler;
pub struct ReadMcpResourceHandler;
#[derive(Debug, Deserialize, Default)]
struct ListResourcesArgs {
@@ -178,9 +181,281 @@ struct ReadResourcePayload {
result: ReadResourceResult,
}
impl ToolHandler for McpResourceHandler {
impl ToolHandler for ListMcpResourcesHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("list_mcp_resources")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource listing reads through the session-owned manager guard"
)]
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"list_mcp_resources handler received unsupported payload".to_string(),
));
}
};
let arguments = parse_arguments(arguments.as_str())?;
let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?;
let ListResourcesArgs { server, cursor } = args;
let server = normalize_optional_string(server);
let cursor = normalize_optional_string(cursor);
let invocation = McpInvocation {
server: server.clone().unwrap_or_else(|| "codex".to_string()),
tool: "list_mcp_resources".to_string(),
arguments: arguments.clone(),
};
emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await;
let start = Instant::now();
let payload_result: Result<ListResourcesPayload, FunctionCallError> = async {
if let Some(server_name) = server.clone() {
let params = cursor.clone().map(|value| PaginatedRequestParams {
meta: None,
cursor: Some(value),
});
let result = session
.list_resources(&server_name, params)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}"))
})?;
Ok(ListResourcesPayload::from_single_server(
server_name,
result,
))
} else {
if cursor.is_some() {
return Err(FunctionCallError::RespondToModel(
"cursor can only be used when a server is specified".to_string(),
));
}
let resources = session
.services
.mcp_connection_manager
.read()
.await
.list_all_resources()
.await;
Ok(ListResourcesPayload::from_all_servers(resources))
}
}
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
let duration = start.elapsed();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Ok(call_tool_result_from_content(&content, output.success)),
)
.await;
Ok(output)
}
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
},
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
}
}
}
impl ToolHandler for ListMcpResourceTemplatesHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("list_mcp_resource_templates")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource template listing reads through the session-owned manager guard"
)]
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"list_mcp_resource_templates handler received unsupported payload".to_string(),
));
}
};
let arguments = parse_arguments(arguments.as_str())?;
let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?;
let ListResourceTemplatesArgs { server, cursor } = args;
let server = normalize_optional_string(server);
let cursor = normalize_optional_string(cursor);
let invocation = McpInvocation {
server: server.clone().unwrap_or_else(|| "codex".to_string()),
tool: "list_mcp_resource_templates".to_string(),
arguments: arguments.clone(),
};
emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await;
let start = Instant::now();
let payload_result: Result<ListResourceTemplatesPayload, FunctionCallError> = async {
if let Some(server_name) = server.clone() {
let params = cursor.clone().map(|value| PaginatedRequestParams {
meta: None,
cursor: Some(value),
});
let result = session
.list_resource_templates(&server_name, params)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"resources/templates/list failed: {err:#}"
))
})?;
Ok(ListResourceTemplatesPayload::from_single_server(
server_name,
result,
))
} else {
if cursor.is_some() {
return Err(FunctionCallError::RespondToModel(
"cursor can only be used when a server is specified".to_string(),
));
}
let templates = session
.services
.mcp_connection_manager
.read()
.await
.list_all_resource_templates()
.await;
Ok(ListResourceTemplatesPayload::from_all_servers(templates))
}
}
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
let duration = start.elapsed();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Ok(call_tool_result_from_content(&content, output.success)),
)
.await;
Ok(output)
}
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
},
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
}
}
}
impl ToolHandler for ReadMcpResourceHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("read_mcp_resource")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -190,7 +465,6 @@ impl ToolHandler for McpResourceHandler {
session,
turn,
call_id,
tool_name,
payload,
..
} = invocation;
@@ -199,124 +473,80 @@ impl ToolHandler for McpResourceHandler {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"mcp_resource handler received unsupported payload".to_string(),
"read_mcp_resource handler received unsupported payload".to_string(),
));
}
};
let arguments_value = parse_arguments(arguments.as_str())?;
let arguments = parse_arguments(arguments.as_str())?;
let args: ReadResourceArgs = parse_args(arguments.clone())?;
let ReadResourceArgs { server, uri } = args;
let server = normalize_required_string("server", server)?;
let uri = normalize_required_string("uri", uri)?;
match tool_name.name.as_str() {
"list_mcp_resources" => {
handle_list_resources(
Arc::clone(&session),
Arc::clone(&turn),
call_id.clone(),
arguments_value.clone(),
)
.await
}
"list_mcp_resource_templates" => {
handle_list_resource_templates(
Arc::clone(&session),
Arc::clone(&turn),
call_id.clone(),
arguments_value.clone(),
)
.await
}
"read_mcp_resource" => {
handle_read_resource(
Arc::clone(&session),
Arc::clone(&turn),
call_id,
arguments_value,
)
.await
}
other => Err(FunctionCallError::RespondToModel(format!(
"unsupported MCP resource tool: {other}"
))),
}
}
}
let invocation = McpInvocation {
server: server.clone(),
tool: "read_mcp_resource".to_string(),
arguments: arguments.clone(),
};
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource listing reads through the session-owned manager guard"
)]
async fn handle_list_resources(
session: Arc<Session>,
turn: Arc<TurnContext>,
call_id: String,
arguments: Option<Value>,
) -> Result<FunctionToolOutput, FunctionCallError> {
let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?;
let ListResourcesArgs { server, cursor } = args;
let server = normalize_optional_string(server);
let cursor = normalize_optional_string(cursor);
emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await;
let start = Instant::now();
let invocation = McpInvocation {
server: server.clone().unwrap_or_else(|| "codex".to_string()),
tool: "list_mcp_resources".to_string(),
arguments: arguments.clone(),
};
emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await;
let start = Instant::now();
let payload_result: Result<ListResourcesPayload, FunctionCallError> = async {
if let Some(server_name) = server.clone() {
let params = cursor.clone().map(|value| PaginatedRequestParams {
meta: None,
cursor: Some(value),
});
let payload_result: Result<ReadResourcePayload, FunctionCallError> = async {
let result = session
.list_resources(&server_name, params)
.read_resource(
&server,
ReadResourceRequestParams {
meta: None,
uri: uri.clone(),
},
)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}"))
FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}"))
})?;
Ok(ListResourcesPayload::from_single_server(
server_name,
Ok(ReadResourcePayload {
server,
uri,
result,
))
} else {
if cursor.is_some() {
return Err(FunctionCallError::RespondToModel(
"cursor can only be used when a server is specified".to_string(),
));
}
let resources = session
.services
.mcp_connection_manager
.read()
.await
.list_all_resources()
.await;
Ok(ListResourcesPayload::from_all_servers(resources))
})
}
}
.await;
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(output) => {
let content =
function_call_output_content_items_to_text(&output.body).unwrap_or_default();
let duration = start.elapsed();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Ok(call_tool_result_from_content(&content, output.success)),
)
.await;
Ok(output)
}
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(output) => {
let content = function_call_output_content_items_to_text(&output.body)
.unwrap_or_default();
let duration = start.elapsed();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Ok(call_tool_result_from_content(&content, output.success)),
)
.await;
Ok(output)
}
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
},
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
@@ -331,221 +561,6 @@ async fn handle_list_resources(
.await;
Err(err)
}
},
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP resource template listing reads through the session-owned manager guard"
)]
async fn handle_list_resource_templates(
session: Arc<Session>,
turn: Arc<TurnContext>,
call_id: String,
arguments: Option<Value>,
) -> Result<FunctionToolOutput, FunctionCallError> {
let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?;
let ListResourceTemplatesArgs { server, cursor } = args;
let server = normalize_optional_string(server);
let cursor = normalize_optional_string(cursor);
let invocation = McpInvocation {
server: server.clone().unwrap_or_else(|| "codex".to_string()),
tool: "list_mcp_resource_templates".to_string(),
arguments: arguments.clone(),
};
emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await;
let start = Instant::now();
let payload_result: Result<ListResourceTemplatesPayload, FunctionCallError> = async {
if let Some(server_name) = server.clone() {
let params = cursor.clone().map(|value| PaginatedRequestParams {
meta: None,
cursor: Some(value),
});
let result = session
.list_resource_templates(&server_name, params)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"resources/templates/list failed: {err:#}"
))
})?;
Ok(ListResourceTemplatesPayload::from_single_server(
server_name,
result,
))
} else {
if cursor.is_some() {
return Err(FunctionCallError::RespondToModel(
"cursor can only be used when a server is specified".to_string(),
));
}
let templates = session
.services
.mcp_connection_manager
.read()
.await
.list_all_resource_templates()
.await;
Ok(ListResourceTemplatesPayload::from_all_servers(templates))
}
}
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(output) => {
let content =
function_call_output_content_items_to_text(&output.body).unwrap_or_default();
let duration = start.elapsed();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Ok(call_tool_result_from_content(&content, output.success)),
)
.await;
Ok(output)
}
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
},
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
}
}
async fn handle_read_resource(
session: Arc<Session>,
turn: Arc<TurnContext>,
call_id: String,
arguments: Option<Value>,
) -> Result<FunctionToolOutput, FunctionCallError> {
let args: ReadResourceArgs = parse_args(arguments.clone())?;
let ReadResourceArgs { server, uri } = args;
let server = normalize_required_string("server", server)?;
let uri = normalize_required_string("uri", uri)?;
let invocation = McpInvocation {
server: server.clone(),
tool: "read_mcp_resource".to_string(),
arguments: arguments.clone(),
};
emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await;
let start = Instant::now();
let payload_result: Result<ReadResourcePayload, FunctionCallError> = async {
let result = session
.read_resource(
&server,
ReadResourceRequestParams {
meta: None,
uri: uri.clone(),
},
)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}"))
})?;
Ok(ReadResourcePayload {
server,
uri,
result,
})
}
.await;
match payload_result {
Ok(payload) => match serialize_function_output(payload) {
Ok(output) => {
let content =
function_call_output_content_items_to_text(&output.body).unwrap_or_default();
let duration = start.elapsed();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Ok(call_tool_result_from_content(&content, output.success)),
)
.await;
Ok(output)
}
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
},
Err(err) => {
let duration = start.elapsed();
let message = err.to_string();
emit_tool_call_end(
&session,
turn.as_ref(),
&call_id,
invocation,
duration,
Err(message.clone()),
)
.await;
Err(err)
}
}
}
+10 -3
View File
@@ -38,20 +38,27 @@ pub use apply_patch::ApplyPatchHandler;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::AskForApproval;
pub use dynamic::DynamicToolHandler;
pub use goal::GoalHandler;
pub use goal::CreateGoalHandler;
pub use goal::GetGoalHandler;
pub use goal::UpdateGoalHandler;
pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use mcp_resource::ListMcpResourceTemplatesHandler;
pub use mcp_resource::ListMcpResourcesHandler;
pub use mcp_resource::ReadMcpResourceHandler;
pub use plan::PlanHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
pub use shell::ContainerExecHandler;
pub use shell::LocalShellHandler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub use tool_search::ToolSearchHandler;
pub use unavailable_tool::UnavailableToolHandler;
pub(crate) use unavailable_tool::unavailable_tool_message;
pub use unified_exec::UnifiedExecHandler;
pub use unified_exec::ExecCommandHandler;
pub use unified_exec::WriteStdinHandler;
pub use view_image::ViewImageHandler;
fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
@@ -32,6 +32,7 @@ use codex_protocol::protocol::CollabResumeEndEvent;
use codex_protocol::protocol::CollabWaitingBeginEvent;
use codex_protocol::protocol::CollabWaitingEndEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -6,6 +6,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = CloseAgentResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("close_agent")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -8,6 +8,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = ResumeAgentResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("resume_agent")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -7,6 +7,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = SendInputResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("send_input")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -13,6 +13,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = SpawnAgentResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("spawn_agent")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -18,6 +18,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = WaitAgentResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("wait_agent")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -22,6 +22,7 @@ use codex_protocol::protocol::CollabCloseEndEvent;
use codex_protocol::protocol::CollabWaitingBeginEvent;
use codex_protocol::protocol::CollabWaitingEndEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -6,6 +6,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = CloseAgentResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("close_agent")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -9,6 +9,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("followup_task")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -6,6 +6,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = ListAgentsResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("list_agents")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -62,15 +62,7 @@ pub(crate) async fn handle_message_string_tool(
target: String,
message: String,
) -> Result<FunctionToolOutput, FunctionCallError> {
handle_message_submission(invocation, mode, target, message_content(message)?).await
}
async fn handle_message_submission(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
target: String,
prompt: String,
) -> Result<FunctionToolOutput, FunctionCallError> {
let prompt = message_content(message)?;
let ToolInvocation {
session,
turn,
@@ -9,6 +9,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("send_message")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -15,6 +15,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = SpawnAgentResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("spawn_agent")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -10,6 +10,10 @@ pub(crate) struct Handler;
impl ToolHandler for Handler {
type Output = WaitAgentResult;
fn tool_name(&self) -> ToolName {
ToolName::plain("wait_agent")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
+16 -25
View File
@@ -1,6 +1,4 @@
use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -11,6 +9,7 @@ use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::EventMsg;
use codex_tools::ToolName;
use serde_json::Value as JsonValue;
pub struct PlanHandler;
@@ -46,6 +45,10 @@ impl ToolOutput for PlanToolOutput {
impl ToolHandler for PlanHandler {
type Output = PlanToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("update_plan")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -54,7 +57,7 @@ impl ToolHandler for PlanHandler {
let ToolInvocation {
session,
turn,
call_id,
call_id: _,
payload,
..
} = invocation;
@@ -68,33 +71,21 @@ impl ToolHandler for PlanHandler {
}
};
handle_update_plan(session.as_ref(), turn.as_ref(), arguments, call_id).await?;
if turn.collaboration_mode.mode == ModeKind::Plan {
return Err(FunctionCallError::RespondToModel(
"update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(),
));
}
let args = parse_update_plan_arguments(&arguments)?;
session
.send_event(turn.as_ref(), EventMsg::PlanUpdate(args))
.await;
Ok(PlanToolOutput)
}
}
/// This function doesn't do anything useful. However, it gives the model a structured way to record its plan that clients can read and render.
/// So it's the _inputs_ to this function that are useful to clients, not the outputs and neither are actually useful for the model other
/// than forcing it to come up and document a plan (TBD how that affects performance).
pub(crate) async fn handle_update_plan(
session: &Session,
turn_context: &TurnContext,
arguments: String,
_call_id: String,
) -> Result<String, FunctionCallError> {
if turn_context.collaboration_mode.mode == ModeKind::Plan {
return Err(FunctionCallError::RespondToModel(
"update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(),
));
}
let args = parse_update_plan_arguments(&arguments)?;
session
.send_event(turn_context, EventMsg::PlanUpdate(args))
.await;
Ok("Plan updated".to_string())
}
fn parse_update_plan_arguments(arguments: &str) -> Result<UpdatePlanArgs, FunctionCallError> {
serde_json::from_str::<UpdatePlanArgs>(arguments).map_err(|e| {
FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e}"))
@@ -8,12 +8,17 @@ use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::ToolName;
pub struct RequestPermissionsHandler;
impl ToolHandler for RequestPermissionsHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("request_permissions")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -13,6 +13,7 @@ use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::RequestPluginInstallArgs;
use codex_tools::RequestPluginInstallResult;
use codex_tools::ToolName;
use codex_tools::all_requested_connectors_picked_up;
use codex_tools::build_request_plugin_install_elicitation_request;
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
@@ -37,6 +38,10 @@ pub struct RequestPluginInstallHandler;
impl ToolHandler for RequestPluginInstallHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME)
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -8,6 +8,7 @@ use crate::tools::registry::ToolKind;
use codex_protocol::config_types::ModeKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_tools::REQUEST_USER_INPUT_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::normalize_request_user_input_args;
use codex_tools::request_user_input_unavailable_message;
@@ -18,6 +19,10 @@ pub struct RequestUserInputHandler {
impl ToolHandler for RequestUserInputHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME)
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
+238 -74
View File
@@ -40,8 +40,11 @@ use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::ExecCommandSource;
use codex_shell_command::is_safe_command::is_known_safe_command;
use codex_tools::ShellCommandBackendConfig;
use codex_tools::ToolName;
pub struct ShellHandler;
pub struct ContainerExecHandler;
pub struct LocalShellHandler;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShellCommandBackend {
@@ -53,16 +56,24 @@ 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(&params.command)),
ToolPayload::LocalShell { params } => Some(codex_shell_command::parse_command::shlex_join(
&params.command,
)),
_ => None,
}
fn shell_function_payload_command(payload: &ToolPayload) -> Option<String> {
let ToolPayload::Function { arguments } = payload else {
return None;
};
parse_arguments::<ShellToolCallParams>(arguments)
.ok()
.map(|params| codex_shell_command::parse_command::shlex_join(&params.command))
}
fn local_shell_payload_command(payload: &ToolPayload) -> Option<String> {
let ToolPayload::LocalShell { params } = payload else {
return None;
};
Some(codex_shell_command::parse_command::shlex_join(
&params.command,
))
}
fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
@@ -182,31 +193,184 @@ impl From<ShellCommandBackendConfig> for ShellCommandHandler {
impl ToolHandler for ShellHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("shell")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(
payload,
ToolPayload::Function { .. } | ToolPayload::LocalShell { .. }
)
matches!(payload, ToolPayload::Function { .. })
}
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
match &invocation.payload {
ToolPayload::Function { arguments } => {
serde_json::from_str::<ShellToolCallParams>(arguments)
.map(|params| !is_known_safe_command(&params.command))
.unwrap_or(true)
}
ToolPayload::LocalShell { params } => !is_known_safe_command(&params.command),
_ => true, // unknown payloads => assume mutating
}
let ToolPayload::Function { arguments } = &invocation.payload else {
return true;
};
serde_json::from_str::<ShellToolCallParams>(arguments)
.map(|params| !is_known_safe_command(&params.command))
.unwrap_or(true)
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
shell_function_pre_tool_use_payload(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
shell_function_post_tool_use_payload(invocation, result)
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"unsupported payload for shell handler".to_string(),
));
}
};
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let prefix_rule = params.prefix_rule.clone();
let exec_params =
ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: "shell".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
})
.await
}
}
impl ToolHandler for ContainerExecHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("container.exec")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
let ToolPayload::Function { arguments } = &invocation.payload else {
return true;
};
serde_json::from_str::<ShellToolCallParams>(arguments)
.map(|params| !is_known_safe_command(&params.command))
.unwrap_or(true)
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
shell_function_pre_tool_use_payload(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
shell_function_post_tool_use_payload(invocation, result)
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"unsupported payload for container.exec handler".to_string(),
));
}
};
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let prefix_rule = params.prefix_rule.clone();
let exec_params =
ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: "container.exec".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
})
.await
}
}
impl ToolHandler for LocalShellHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("local_shell")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::LocalShell { .. })
}
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
let ToolPayload::LocalShell { params } = &invocation.payload else {
return true;
};
!is_known_safe_command(&params.command)
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
local_shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
tool_name: HookToolName::bash(),
tool_input: serde_json::json!({ "command": command }),
})
@@ -219,7 +383,7 @@ impl ToolHandler for ShellHandler {
) -> Option<PostToolUsePayload> {
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
let command = shell_payload_command(&invocation.payload)?;
let command = local_shell_payload_command(&invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: invocation.call_id.clone(),
@@ -234,62 +398,63 @@ impl ToolHandler for ShellHandler {
turn,
tracker,
call_id,
tool_name,
payload,
..
} = invocation;
match payload {
ToolPayload::Function { arguments } => {
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
let prefix_rule = params.prefix_rule.clone();
let exec_params =
Self::to_exec_params(&params, turn.as_ref(), session.conversation_id);
Self::run_exec_like(RunExecLikeArgs {
tool_name: tool_name.display(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
})
.await
}
ToolPayload::LocalShell { params } => {
let exec_params =
Self::to_exec_params(&params, turn.as_ref(), session.conversation_id);
Self::run_exec_like(RunExecLikeArgs {
tool_name: tool_name.display(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: None,
prefix_rule: None,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
})
.await
}
_ => Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for shell handler: {}",
tool_name.display()
))),
}
let ToolPayload::LocalShell { params } = payload else {
return Err(FunctionCallError::RespondToModel(
"unsupported payload for local_shell handler".to_string(),
));
};
let exec_params =
ShellHandler::to_exec_params(&params, turn.as_ref(), session.conversation_id);
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: "local_shell".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
additional_permissions: None,
prefix_rule: None,
session,
turn,
tracker,
call_id,
freeform: false,
shell_runtime_backend: ShellRuntimeBackend::Generic,
})
.await
}
}
fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
shell_function_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
tool_name: HookToolName::bash(),
tool_input: serde_json::json!({ "command": command }),
})
}
fn shell_function_post_tool_use_payload(
invocation: &ToolInvocation,
result: &FunctionToolOutput,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
let command = shell_function_payload_command(&invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: invocation.call_id.clone(),
tool_input: serde_json::json!({ "command": command }),
tool_response,
})
}
impl ToolHandler for ShellCommandHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("shell_command")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -348,7 +513,6 @@ impl ToolHandler for ShellCommandHandler {
turn,
tracker,
call_id,
tool_name,
payload,
..
} = invocation;
@@ -356,7 +520,7 @@ impl ToolHandler for ShellCommandHandler {
let ToolPayload::Function { arguments } = payload else {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for shell_command handler: {}",
tool_name.display()
self.tool_name().display()
)));
};
@@ -379,7 +543,7 @@ impl ToolHandler for ShellCommandHandler {
turn.tools_config.allow_login_shell,
)?;
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: tool_name.display(),
tool_name: self.tool_name().display(),
exec_params,
hook_command: params.command,
additional_permissions: params.additional_permissions.clone(),
@@ -16,8 +16,8 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::LocalShellHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
@@ -204,7 +204,7 @@ fn shell_command_handler_rejects_login_when_disallowed() {
}
#[tokio::test]
async fn shell_pre_tool_use_payload_uses_joined_command() {
async fn local_shell_pre_tool_use_payload_uses_joined_command() {
let payload = ToolPayload::LocalShell {
params: codex_protocol::models::ShellToolCallParams {
command: vec![
@@ -215,13 +215,13 @@ async fn shell_pre_tool_use_payload_uses_joined_command() {
workdir: None,
timeout_ms: None,
sandbox_permissions: None,
prefix_rule: None,
additional_permissions: None,
prefix_rule: None,
justification: None,
},
};
let (session, turn) = make_session_and_context().await;
let handler = ShellHandler;
let handler = LocalShellHandler;
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
@@ -230,7 +230,7 @@ async fn shell_pre_tool_use_payload_uses_joined_command() {
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-41".to_string(),
tool_name: codex_tools::ToolName::plain("shell"),
tool_name: codex_tools::ToolName::plain("local_shell"),
source: crate::tools::context::ToolCallSource::Direct,
payload,
}),
@@ -15,6 +15,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::ToolName;
pub struct TestSyncHandler;
@@ -56,6 +57,10 @@ fn barrier_map() -> &'static tokio::sync::Mutex<HashMap<String, BarrierState>> {
impl ToolHandler for TestSyncHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("test_sync_tool")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -12,6 +12,7 @@ use bm25::SearchEngineBuilder;
use codex_tools::LoadableToolSpec;
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::coalesce_loadable_tool_specs;
use std::collections::HashMap;
@@ -44,6 +45,10 @@ impl ToolSearchHandler {
impl ToolHandler for ToolSearchHandler {
type Output = ToolSearchOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain(TOOL_SEARCH_TOOL_NAME)
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -4,8 +4,17 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::ToolName;
pub struct UnavailableToolHandler;
pub struct UnavailableToolHandler {
tool_name: ToolName,
}
impl UnavailableToolHandler {
pub fn new(tool_name: ToolName) -> Self {
Self { tool_name }
}
}
pub(crate) fn unavailable_tool_message(
tool_name: impl std::fmt::Display,
@@ -19,19 +28,21 @@ pub(crate) fn unavailable_tool_message(
impl ToolHandler for UnavailableToolHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> ToolName {
self.tool_name.clone()
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
tool_name, payload, ..
} = invocation;
let ToolInvocation { payload, .. } = invocation;
match payload {
ToolPayload::Function { .. } => Ok(FunctionToolOutput::from_text(
unavailable_tool_message(
tool_name.display(),
self.tool_name.display(),
"Retry after the tool becomes available or ask the user to re-enable it.",
),
Some(false),
+279 -243
View File
@@ -33,6 +33,7 @@ use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TerminalInteractionEvent;
use codex_shell_command::is_safe_command::is_known_safe_command;
use codex_tools::ToolName;
use codex_tools::UnifiedExecShellMode;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::approx_token_count;
@@ -40,7 +41,8 @@ use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
pub struct UnifiedExecHandler;
pub struct ExecCommandHandler;
pub struct WriteStdinHandler;
#[derive(Debug, Deserialize)]
pub(crate) struct ExecCommandArgs {
@@ -108,9 +110,13 @@ fn effective_max_output_tokens(
resolve_max_tokens(max_output_tokens).min(truncation_policy.token_budget())
}
impl ToolHandler for UnifiedExecHandler {
impl ToolHandler for ExecCommandHandler {
type Output = ExecCommandToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("exec_command")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -144,12 +150,6 @@ impl ToolHandler for UnifiedExecHandler {
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
if invocation.tool_name.namespace.is_some()
|| invocation.tool_name.name.as_str() != "exec_command"
{
return None;
}
let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};
@@ -167,23 +167,7 @@ impl ToolHandler for UnifiedExecHandler {
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let ToolPayload::Function { .. } = &invocation.payload else {
return None;
};
let command = result.hook_command.clone()?;
let tool_use_id = if result.event_call_id.is_empty() {
invocation.call_id.clone()
} else {
result.event_call_id.clone()
};
let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id,
tool_input: serde_json::json!({ "command": command }),
tool_response,
})
post_unified_exec_tool_use_payload(invocation, result)
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
@@ -192,7 +176,6 @@ impl ToolHandler for UnifiedExecHandler {
turn,
tracker,
call_id,
tool_name,
payload,
..
} = invocation;
@@ -201,239 +184,292 @@ impl ToolHandler for UnifiedExecHandler {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"unified_exec handler received unsupported payload".to_string(),
"exec_command handler received unsupported payload".to_string(),
));
}
};
let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());
let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?;
let Some(turn_environment) =
resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())?
else {
return Err(FunctionCallError::RespondToModel(
"unified exec is unavailable in this session".to_string(),
));
};
let cwd = environment_args
.workdir
.as_deref()
.filter(|workdir| !workdir.is_empty())
.map_or_else(
|| turn_environment.cwd.clone(),
|workdir| turn_environment.cwd.join(workdir),
);
let environment = Arc::clone(&turn_environment.environment);
let fs = environment.get_filesystem();
let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
let hook_command = args.cmd.clone();
maybe_emit_implicit_skill_invocation(
session.as_ref(),
context.turn.as_ref(),
&hook_command,
&cwd,
)
.await;
let process_id = manager.allocate_process_id().await;
let command = get_command(
&args,
session.user_shell(),
&turn.tools_config.unified_exec_shell_mode,
turn.tools_config.allow_login_shell,
)
.map_err(FunctionCallError::RespondToModel)?;
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
let response = match tool_name.name.as_str() {
"exec_command" => {
let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?;
let Some(turn_environment) = resolve_tool_environment(
turn.as_ref(),
environment_args.environment_id.as_deref(),
)?
else {
return Err(FunctionCallError::RespondToModel(
"unified exec is unavailable in this session".to_string(),
));
};
let cwd = environment_args
.workdir
.as_deref()
.filter(|workdir| !workdir.is_empty())
.map_or_else(
|| turn_environment.cwd.clone(),
|workdir| turn_environment.cwd.join(workdir),
);
let environment = Arc::clone(&turn_environment.environment);
let fs = environment.get_filesystem();
let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
let hook_command = args.cmd.clone();
maybe_emit_implicit_skill_invocation(
session.as_ref(),
context.turn.as_ref(),
&hook_command,
let ExecCommandArgs {
tty,
yield_time_ms,
max_output_tokens,
sandbox_permissions,
additional_permissions,
justification,
prefix_rule,
..
} = args;
let max_output_tokens =
effective_max_output_tokens(max_output_tokens, turn.truncation_policy);
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
let effective_additional_permissions = apply_granted_turn_permissions(
context.session.as_ref(),
cwd.as_path(),
sandbox_permissions,
additional_permissions,
)
.await;
let additional_permissions_allowed = exec_permission_approvals_enabled
|| (session.features().enabled(Feature::RequestPermissionsTool)
&& effective_additional_permissions.permissions_preapproved);
// Sticky turn permissions have already been approved, so they should
// continue through the normal exec approval flow for the command.
if effective_additional_permissions
.sandbox_permissions
.requests_sandbox_override()
&& !effective_additional_permissions.permissions_preapproved
&& !matches!(
context.turn.approval_policy.value(),
codex_protocol::protocol::AskForApproval::OnRequest
)
{
let approval_policy = context.turn.approval_policy.value();
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
)));
}
let normalized_additional_permissions = match implicit_granted_permissions(
sandbox_permissions,
requested_additional_permissions.as_ref(),
&effective_additional_permissions,
)
.map_or_else(
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
context.turn.approval_policy.value(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&cwd,
)
.await;
let process_id = manager.allocate_process_id().await;
let command = get_command(
&args,
session.user_shell(),
&turn.tools_config.unified_exec_shell_mode,
turn.tools_config.allow_login_shell,
)
.map_err(FunctionCallError::RespondToModel)?;
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
let ExecCommandArgs {
tty,
yield_time_ms,
max_output_tokens,
sandbox_permissions,
additional_permissions,
justification,
prefix_rule,
..
} = args;
let max_output_tokens =
effective_max_output_tokens(max_output_tokens, turn.truncation_policy);
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
let effective_additional_permissions = apply_granted_turn_permissions(
context.session.as_ref(),
cwd.as_path(),
sandbox_permissions,
additional_permissions,
)
.await;
let additional_permissions_allowed = exec_permission_approvals_enabled
|| (session.features().enabled(Feature::RequestPermissionsTool)
&& effective_additional_permissions.permissions_preapproved);
// Sticky turn permissions have already been approved, so they should
// continue through the normal exec approval flow for the command.
if effective_additional_permissions
.sandbox_permissions
.requests_sandbox_override()
&& !effective_additional_permissions.permissions_preapproved
&& !matches!(
context.turn.approval_policy.value(),
codex_protocol::protocol::AskForApproval::OnRequest
)
{
let approval_policy = context.turn.approval_policy.value();
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
)));
}
let normalized_additional_permissions = match implicit_granted_permissions(
sandbox_permissions,
requested_additional_permissions.as_ref(),
&effective_additional_permissions,
)
.map_or_else(
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
context.turn.approval_policy.value(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&cwd,
)
},
|permissions| Ok(Some(permissions)),
) {
Ok(normalized) => normalized,
Err(err) => {
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(err));
}
};
if let Some(output) = intercept_apply_patch(
&command,
&cwd,
fs.as_ref(),
context.session.clone(),
context.turn.clone(),
Some(&tracker),
&context.call_id,
&tool_name.name,
)
.await?
{
manager.release_process_id(process_id).await;
return Ok(ExecCommandToolOutput {
event_call_id: String::new(),
chunk_id: String::new(),
wall_time: std::time::Duration::ZERO,
raw_output: output.into_text().into_bytes(),
max_output_tokens: Some(max_output_tokens),
process_id: None,
exit_code: None,
original_token_count: None,
hook_command: None,
});
}
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
match manager
.exec_command(
ExecCommandRequest {
command,
hook_command: hook_command.clone(),
process_id,
yield_time_ms,
max_output_tokens: Some(max_output_tokens),
cwd,
environment,
network: context.turn.network.clone(),
tty,
sandbox_permissions: effective_additional_permissions
.sandbox_permissions,
additional_permissions: normalized_additional_permissions,
additional_permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
justification,
prefix_rule,
},
&context,
)
.await
{
Ok(response) => response,
Err(UnifiedExecError::SandboxDenied { output, .. }) => {
let output_text = output.aggregated_output.text;
let original_token_count = approx_token_count(&output_text);
ExecCommandToolOutput {
event_call_id: context.call_id.clone(),
chunk_id: generate_chunk_id(),
wall_time: output.duration,
raw_output: output_text.into_bytes(),
max_output_tokens: Some(max_output_tokens),
// Sandbox denial is terminal, so there is no live
// process for write_stdin to resume.
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
hook_command: Some(hook_command),
}
}
Err(err) => {
return Err(FunctionCallError::RespondToModel(format!(
"exec_command failed for `{command_for_display}`: {err:?}"
)));
}
}
}
"write_stdin" => {
let args: WriteStdinArgs = parse_arguments(&arguments)?;
let max_output_tokens =
effective_max_output_tokens(args.max_output_tokens, turn.truncation_policy);
let response = manager
.write_stdin(WriteStdinRequest {
process_id: args.session_id,
input: &args.chars,
yield_time_ms: args.yield_time_ms,
max_output_tokens: Some(max_output_tokens),
})
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("write_stdin failed: {err}"))
})?;
let interaction = TerminalInteractionEvent {
call_id: response.event_call_id.clone(),
process_id: args.session_id.to_string(),
stdin: args.chars.clone(),
};
session
.send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction))
.await;
response
}
other => {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported unified exec function {other}"
)));
},
|permissions| Ok(Some(permissions)),
) {
Ok(normalized) => normalized,
Err(err) => {
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(err));
}
};
if let Some(output) = intercept_apply_patch(
&command,
&cwd,
fs.as_ref(),
context.session.clone(),
context.turn.clone(),
Some(&tracker),
&context.call_id,
"exec_command",
)
.await?
{
manager.release_process_id(process_id).await;
return Ok(ExecCommandToolOutput {
event_call_id: String::new(),
chunk_id: String::new(),
wall_time: std::time::Duration::ZERO,
raw_output: output.into_text().into_bytes(),
max_output_tokens: Some(max_output_tokens),
process_id: None,
exit_code: None,
original_token_count: None,
hook_command: None,
});
}
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
match manager
.exec_command(
ExecCommandRequest {
command,
hook_command: hook_command.clone(),
process_id,
yield_time_ms,
max_output_tokens: Some(max_output_tokens),
cwd,
environment,
network: context.turn.network.clone(),
tty,
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,
additional_permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
justification,
prefix_rule,
},
&context,
)
.await
{
Ok(response) => Ok(response),
Err(UnifiedExecError::SandboxDenied { output, .. }) => {
let output_text = output.aggregated_output.text;
let original_token_count = approx_token_count(&output_text);
Ok(ExecCommandToolOutput {
event_call_id: context.call_id.clone(),
chunk_id: generate_chunk_id(),
wall_time: output.duration,
raw_output: output_text.into_bytes(),
max_output_tokens: Some(max_output_tokens),
// Sandbox denial is terminal, so there is no live
// process for write_stdin to resume.
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
hook_command: Some(hook_command),
})
}
Err(err) => Err(FunctionCallError::RespondToModel(format!(
"exec_command failed for `{command_for_display}`: {err:?}"
))),
}
}
}
impl ToolHandler for WriteStdinHandler {
type Output = ExecCommandToolOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("write_stdin")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
true
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
post_unified_exec_tool_use_payload(invocation, result)
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"write_stdin handler received unsupported payload".to_string(),
));
}
};
let args: WriteStdinArgs = parse_arguments(&arguments)?;
let max_output_tokens =
effective_max_output_tokens(args.max_output_tokens, turn.truncation_policy);
let response = session
.services
.unified_exec_manager
.write_stdin(WriteStdinRequest {
process_id: args.session_id,
input: &args.chars,
yield_time_ms: args.yield_time_ms,
max_output_tokens: Some(max_output_tokens),
})
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("write_stdin failed: {err}"))
})?;
let interaction = TerminalInteractionEvent {
call_id: response.event_call_id.clone(),
process_id: args.session_id.to_string(),
stdin: args.chars.clone(),
};
session
.send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction))
.await;
Ok(response)
}
}
fn post_unified_exec_tool_use_payload(
invocation: &ToolInvocation,
result: &ExecCommandToolOutput,
) -> Option<PostToolUsePayload> {
let ToolPayload::Function { .. } = &invocation.payload else {
return None;
};
let command = result.hook_command.clone()?;
let tool_use_id = if result.event_call_id.is_empty() {
invocation.call_id.clone()
} else {
result.event_call_id.clone()
};
let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id,
tool_input: serde_json::json!({ "command": command }),
tool_response,
})
}
fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) {
session_telemetry.counter(
TOOL_CALL_UNIFIED_EXEC_METRIC,
@@ -184,7 +184,7 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() {
arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = UnifiedExecHandler;
let handler = ExecCommandHandler;
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
@@ -210,7 +210,7 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() {
arguments: serde_json::json!({ "chars": "echo hi" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = UnifiedExecHandler;
let handler = WriteStdinHandler;
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
@@ -244,8 +244,9 @@ async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_s
hook_command: Some("echo three".to_string()),
};
let invocation = invocation_for_payload("exec_command", "call-43", payload).await;
let handler = ExecCommandHandler;
assert_eq!(
UnifiedExecHandler.post_tool_use_payload(&invocation, &output),
handler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-43".to_string(),
@@ -272,9 +273,10 @@ async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completi
hook_command: Some("echo three".to_string()),
};
let invocation = invocation_for_payload("exec_command", "call-44", payload).await;
let handler = ExecCommandHandler;
assert_eq!(
UnifiedExecHandler.post_tool_use_payload(&invocation, &output),
handler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-44".to_string(),
@@ -301,10 +303,8 @@ async fn exec_command_post_tool_use_payload_skips_running_sessions() {
hook_command: Some("echo three".to_string()),
};
let invocation = invocation_for_payload("exec_command", "call-45", payload).await;
assert_eq!(
UnifiedExecHandler.post_tool_use_payload(&invocation, &output),
None
);
let handler = ExecCommandHandler;
assert_eq!(handler.post_tool_use_payload(&invocation, &output), None);
}
#[tokio::test]
@@ -328,9 +328,10 @@ async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_comman
hook_command: Some("sleep 1; echo finished".to_string()),
};
let invocation = invocation_for_payload("write_stdin", "write-stdin-call", payload).await;
let handler = WriteStdinHandler;
assert_eq!(
UnifiedExecHandler.post_tool_use_payload(&invocation, &output),
handler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-45".to_string(),
@@ -369,10 +370,11 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ
};
let invocation_b = invocation_for_payload("write_stdin", "write-call-b", payload.clone()).await;
let invocation_a = invocation_for_payload("write_stdin", "write-call-a", payload).await;
let handler = WriteStdinHandler;
let payloads = [
UnifiedExecHandler.post_tool_use_payload(&invocation_b, &output_b),
UnifiedExecHandler.post_tool_use_payload(&invocation_a, &output_a),
handler.post_tool_use_payload(&invocation_b, &output_b),
handler.post_tool_use_payload(&invocation_a, &output_a),
];
assert_eq!(
@@ -19,6 +19,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_tools::ToolName;
pub struct ViewImageHandler;
@@ -39,6 +40,10 @@ enum ViewImageDetail {
impl ToolHandler for ViewImageHandler {
type Output = ViewImageOutput;
fn tool_name(&self) -> ToolName {
ToolName::plain("view_image")
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
+7 -29
View File
@@ -44,6 +44,9 @@ pub enum ToolKind {
pub trait ToolHandler: Send + Sync {
type Output: ToolOutput + 'static;
/// The concrete tool name handled by this handler instance.
fn tool_name(&self) -> ToolName;
fn kind(&self) -> ToolKind;
fn matches_kind(&self, payload: &ToolPayload) -> bool {
@@ -227,10 +230,11 @@ impl ToolRegistry {
}
#[cfg(test)]
pub(crate) fn with_handler_for_test<T>(name: ToolName, handler: Arc<T>) -> Self
pub(crate) fn with_handler_for_test<T>(handler: Arc<T>) -> Self
where
T: ToolHandler + 'static,
{
let name = handler.tool_name();
Self::new(HashMap::from([(name, handler as Arc<dyn AnyToolHandler>)]))
}
@@ -250,14 +254,6 @@ impl ToolRegistry {
self.handler(name)?.create_diff_consumer()
}
// TODO(jif) for dynamic tools.
// pub fn register(&mut self, name: impl Into<String>, handler: Arc<dyn ToolHandler>) {
// let name = name.into();
// if self.handlers.insert(name.clone(), handler).is_some() {
// warn!("overwriting handler for tool {name}");
// }
// }
#[expect(
clippy::await_holding_invalid_type,
reason = "tool dispatch must keep active-turn accounting atomic"
@@ -539,11 +535,11 @@ impl ToolRegistryBuilder {
.push(ConfiguredToolSpec::new(spec, supports_parallel_tool_calls));
}
pub fn register_handler<H>(&mut self, name: impl Into<ToolName>, handler: Arc<H>)
pub fn register_handler<H>(&mut self, handler: Arc<H>)
where
H: ToolHandler + 'static,
{
let name = name.into();
let name = handler.tool_name();
let display_name = name.display();
let handler: Arc<dyn AnyToolHandler> = handler;
if self.handlers.insert(name, handler).is_some() {
@@ -551,24 +547,6 @@ impl ToolRegistryBuilder {
}
}
// TODO(jif) for dynamic tools.
// pub fn register_many<I>(&mut self, names: I, handler: Arc<dyn ToolHandler>)
// where
// I: IntoIterator,
// I::Item: Into<String>,
// {
// for name in names {
// let name = name.into();
// if self
// .handlers
// .insert(name.clone(), handler.clone())
// .is_some()
// {
// warn!("overwriting handler for tool {name}");
// }
// }
// }
pub fn build(self) -> (Vec<ConfiguredToolSpec>, ToolRegistry) {
let registry = ToolRegistry::new(self.handlers);
(self.specs, registry)
+13 -4
View File
@@ -1,12 +1,17 @@
use super::*;
use pretty_assertions::assert_eq;
#[derive(Default)]
struct TestHandler;
struct TestHandler {
tool_name: codex_tools::ToolName,
}
impl ToolHandler for TestHandler {
type Output = crate::tools::context::FunctionToolOutput;
fn tool_name(&self) -> codex_tools::ToolName {
self.tool_name.clone()
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -21,12 +26,16 @@ impl ToolHandler for TestHandler {
#[test]
fn handler_looks_up_namespaced_aliases_explicitly() {
let plain_handler = Arc::new(TestHandler) as Arc<dyn AnyToolHandler>;
let namespaced_handler = Arc::new(TestHandler) as Arc<dyn AnyToolHandler>;
let namespace = "mcp__codex_apps__gmail";
let tool_name = "gmail_get_recent_emails";
let plain_name = codex_tools::ToolName::plain(tool_name);
let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name);
let plain_handler = Arc::new(TestHandler {
tool_name: plain_name.clone(),
}) as Arc<dyn AnyToolHandler>;
let namespaced_handler = Arc::new(TestHandler {
tool_name: namespaced_name.clone(),
}) as Arc<dyn AnyToolHandler>;
let registry = ToolRegistry::new(HashMap::from([
(plain_name.clone(), Arc::clone(&plain_handler)),
(namespaced_name.clone(), Arc::clone(&namespaced_handler)),
+84 -71
View File
@@ -1,6 +1,7 @@
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::handlers::agent_jobs::BatchJobHandler;
use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler;
use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler;
use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS;
@@ -76,11 +77,17 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::CodeModeExecuteHandler;
use crate::tools::handlers::CodeModeWaitHandler;
use crate::tools::handlers::ContainerExecHandler;
use crate::tools::handlers::CreateGoalHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::GoalHandler;
use crate::tools::handlers::ExecCommandHandler;
use crate::tools::handlers::GetGoalHandler;
use crate::tools::handlers::ListMcpResourceTemplatesHandler;
use crate::tools::handlers::ListMcpResourcesHandler;
use crate::tools::handlers::LocalShellHandler;
use crate::tools::handlers::McpHandler;
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::ReadMcpResourceHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputHandler;
@@ -89,8 +96,9 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::UnavailableToolHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::UpdateGoalHandler;
use crate::tools::handlers::ViewImageHandler;
use crate::tools::handlers::WriteStdinHandler;
use crate::tools::handlers::multi_agents::CloseAgentHandler;
use crate::tools::handlers::multi_agents::ResumeAgentHandler;
use crate::tools::handlers::multi_agents::SendInputHandler;
@@ -150,30 +158,11 @@ pub(crate) fn build_specs_with_discoverable_tools(
},
},
);
let shell_handler = Arc::new(ShellHandler);
let unified_exec_handler = Arc::new(UnifiedExecHandler);
let plan_handler = Arc::new(PlanHandler);
let apply_patch_handler = Arc::new(ApplyPatchHandler);
let dynamic_tool_handler = Arc::new(DynamicToolHandler);
let goal_handler = Arc::new(GoalHandler);
let view_image_handler = Arc::new(ViewImageHandler);
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend));
let request_permissions_handler = Arc::new(RequestPermissionsHandler);
let request_user_input_handler = Arc::new(RequestUserInputHandler {
available_modes: config.request_user_input_available_modes.clone(),
});
let deferred_dynamic_tools = dynamic_tools
.iter()
.filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none()))
.cloned()
.collect::<Vec<_>>();
let mut tool_search_handler = None;
let request_plugin_install_handler = Arc::new(RequestPluginInstallHandler);
let code_mode_handler = Arc::new(CodeModeExecuteHandler);
let code_mode_wait_handler = Arc::new(CodeModeWaitHandler);
let unavailable_tool_handler = Arc::new(UnavailableToolHandler);
let mut existing_spec_names = plan
.specs
.iter()
@@ -191,113 +180,137 @@ pub(crate) fn build_specs_with_discoverable_tools(
}
for handler in plan.handlers {
let name = handler.name;
match handler.kind {
ToolHandlerKind::AgentJobs => {
builder.register_handler(handler.name, Arc::new(BatchJobHandler));
}
ToolHandlerKind::ApplyPatch => {
builder.register_handler(handler.name, apply_patch_handler.clone());
builder.register_handler(Arc::new(ApplyPatchHandler));
}
ToolHandlerKind::CloseAgentV1 => {
builder.register_handler(handler.name, Arc::new(CloseAgentHandler));
builder.register_handler(Arc::new(CloseAgentHandler));
}
ToolHandlerKind::CloseAgentV2 => {
builder.register_handler(handler.name, Arc::new(CloseAgentHandlerV2));
builder.register_handler(Arc::new(CloseAgentHandlerV2));
}
ToolHandlerKind::CodeModeExecute => {
builder.register_handler(handler.name, code_mode_handler.clone());
builder.register_handler(Arc::new(CodeModeExecuteHandler));
}
ToolHandlerKind::CodeModeWait => {
builder.register_handler(handler.name, code_mode_wait_handler.clone());
builder.register_handler(Arc::new(CodeModeWaitHandler));
}
ToolHandlerKind::ContainerExec => {
builder.register_handler(Arc::new(ContainerExecHandler));
}
ToolHandlerKind::CreateGoal => {
builder.register_handler(Arc::new(CreateGoalHandler));
}
ToolHandlerKind::DynamicTool => {
builder.register_handler(handler.name, dynamic_tool_handler.clone());
builder.register_handler(Arc::new(DynamicToolHandler::new(name)));
}
ToolHandlerKind::ExecCommand => {
builder.register_handler(Arc::new(ExecCommandHandler));
}
ToolHandlerKind::FollowupTaskV2 => {
builder.register_handler(handler.name, Arc::new(FollowupTaskHandlerV2));
builder.register_handler(Arc::new(FollowupTaskHandlerV2));
}
ToolHandlerKind::Goal => {
builder.register_handler(handler.name, goal_handler.clone());
ToolHandlerKind::GetGoal => {
builder.register_handler(Arc::new(GetGoalHandler));
}
ToolHandlerKind::ListAgentsV2 => {
builder.register_handler(handler.name, Arc::new(ListAgentsHandlerV2));
builder.register_handler(Arc::new(ListAgentsHandlerV2));
}
ToolHandlerKind::ListMcpResources => {
builder.register_handler(Arc::new(ListMcpResourcesHandler));
}
ToolHandlerKind::ListMcpResourceTemplates => {
builder.register_handler(Arc::new(ListMcpResourceTemplatesHandler));
}
ToolHandlerKind::LocalShell => {
builder.register_handler(Arc::new(LocalShellHandler));
}
ToolHandlerKind::Mcp => {
builder.register_handler(handler.name, mcp_handler.clone());
}
ToolHandlerKind::McpResource => {
builder.register_handler(handler.name, mcp_resource_handler.clone());
builder.register_handler(Arc::new(McpHandler::new(name)));
}
ToolHandlerKind::Plan => {
builder.register_handler(handler.name, plan_handler.clone());
builder.register_handler(Arc::new(PlanHandler));
}
ToolHandlerKind::ReadMcpResource => {
builder.register_handler(Arc::new(ReadMcpResourceHandler));
}
ToolHandlerKind::ReportAgentJobResult => {
builder.register_handler(Arc::new(ReportAgentJobResultHandler));
}
ToolHandlerKind::RequestPermissions => {
builder.register_handler(handler.name, request_permissions_handler.clone());
builder.register_handler(Arc::new(RequestPermissionsHandler));
}
ToolHandlerKind::RequestUserInput => {
builder.register_handler(handler.name, request_user_input_handler.clone());
builder.register_handler(Arc::new(RequestUserInputHandler {
available_modes: config.request_user_input_available_modes.clone(),
}));
}
ToolHandlerKind::ResumeAgentV1 => {
builder.register_handler(handler.name, Arc::new(ResumeAgentHandler));
builder.register_handler(Arc::new(ResumeAgentHandler));
}
ToolHandlerKind::SendInputV1 => {
builder.register_handler(handler.name, Arc::new(SendInputHandler));
builder.register_handler(Arc::new(SendInputHandler));
}
ToolHandlerKind::SendMessageV2 => {
builder.register_handler(handler.name, Arc::new(SendMessageHandlerV2));
builder.register_handler(Arc::new(SendMessageHandlerV2));
}
ToolHandlerKind::Shell => {
builder.register_handler(handler.name, shell_handler.clone());
builder.register_handler(Arc::new(ShellHandler));
}
ToolHandlerKind::ShellCommand => {
builder.register_handler(handler.name, shell_command_handler.clone());
builder.register_handler(Arc::new(ShellCommandHandler::from(
config.shell_command_backend,
)));
}
ToolHandlerKind::SpawnAgentsOnCsv => {
builder.register_handler(Arc::new(SpawnAgentsOnCsvHandler));
}
ToolHandlerKind::SpawnAgentV1 => {
builder.register_handler(handler.name, Arc::new(SpawnAgentHandler));
builder.register_handler(Arc::new(SpawnAgentHandler));
}
ToolHandlerKind::SpawnAgentV2 => {
builder.register_handler(handler.name, Arc::new(SpawnAgentHandlerV2));
builder.register_handler(Arc::new(SpawnAgentHandlerV2));
}
ToolHandlerKind::TestSync => {
builder.register_handler(handler.name, Arc::new(TestSyncHandler));
builder.register_handler(Arc::new(TestSyncHandler));
}
ToolHandlerKind::ToolSearch => {
if tool_search_handler.is_none() {
let entries = build_tool_search_entries_for_config(
config,
deferred_mcp_tools.as_ref(),
&deferred_dynamic_tools,
);
tool_search_handler = Some(Arc::new(ToolSearchHandler::new(entries)));
}
if let Some(tool_search_handler) = tool_search_handler.as_ref() {
builder.register_handler(handler.name, tool_search_handler.clone());
}
let entries = build_tool_search_entries_for_config(
config,
deferred_mcp_tools.as_ref(),
&deferred_dynamic_tools,
);
builder.register_handler(Arc::new(ToolSearchHandler::new(entries)));
}
ToolHandlerKind::RequestPluginInstall => {
builder.register_handler(handler.name, request_plugin_install_handler.clone());
builder.register_handler(Arc::new(RequestPluginInstallHandler));
}
ToolHandlerKind::UnifiedExec => {
builder.register_handler(handler.name, unified_exec_handler.clone());
ToolHandlerKind::UpdateGoal => {
builder.register_handler(Arc::new(UpdateGoalHandler));
}
ToolHandlerKind::ViewImage => {
builder.register_handler(handler.name, view_image_handler.clone());
builder.register_handler(Arc::new(ViewImageHandler));
}
ToolHandlerKind::WaitAgentV1 => {
builder.register_handler(handler.name, Arc::new(WaitAgentHandler));
builder.register_handler(Arc::new(WaitAgentHandler));
}
ToolHandlerKind::WaitAgentV2 => {
builder.register_handler(handler.name, Arc::new(WaitAgentHandlerV2));
builder.register_handler(Arc::new(WaitAgentHandlerV2));
}
ToolHandlerKind::WriteStdin => {
builder.register_handler(Arc::new(WriteStdinHandler));
}
}
}
if let Some(deferred_mcp_tools) = deferred_mcp_tools.as_ref() {
for (name, _) in deferred_mcp_tools.iter().filter(|(name, _)| {
for (_, tool) in deferred_mcp_tools.iter().filter(|(name, _)| {
!mcp_tools
.as_ref()
.is_some_and(|tools| tools.contains_key(*name))
}) {
builder.register_handler(name.clone(), mcp_handler.clone());
builder.register_handler(Arc::new(McpHandler::new(tool.canonical_tool_name())));
}
}
@@ -326,7 +339,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
};
builder.push_spec(spec);
}
builder.register_handler(unavailable_tool, unavailable_tool_handler.clone());
builder.register_handler(Arc::new(UnavailableToolHandler::new(unavailable_tool)));
}
builder
}
@@ -26,12 +26,17 @@ use crate::tools::registry::ToolKind;
use crate::tools::registry::ToolRegistry;
use crate::turn_diff_tracker::TurnDiffTracker;
#[derive(Default)]
struct TestHandler;
struct TestHandler {
tool_name: codex_tools::ToolName,
}
impl ToolHandler for TestHandler {
type Output = FunctionToolOutput;
fn tool_name(&self) -> codex_tools::ToolName {
self.tool_name.clone()
}
fn kind(&self) -> ToolKind {
ToolKind::Function
}
@@ -53,10 +58,9 @@ async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> a
"await tools.test_tool({})",
);
let registry = ToolRegistry::with_handler_for_test(
codex_tools::ToolName::plain("test_tool"),
Arc::new(TestHandler),
);
let registry = ToolRegistry::with_handler_for_test(Arc::new(TestHandler {
tool_name: codex_tools::ToolName::plain("test_tool"),
}));
let session = Arc::new(session);
let turn = Arc::new(turn);
@@ -165,10 +169,9 @@ async fn dispatch_lifecycle_trace_records_incompatible_payload_failures() -> any
let (mut session, turn) = make_session_and_context().await;
attach_test_trace(&mut session, &turn, temp.path())?;
let registry = ToolRegistry::with_handler_for_test(
codex_tools::ToolName::plain("test_tool"),
Arc::new(TestHandler),
);
let registry = ToolRegistry::with_handler_for_test(Arc::new(TestHandler {
tool_name: codex_tools::ToolName::plain("test_tool"),
}));
let session = Arc::new(session);
let turn = Arc::new(turn);
@@ -200,10 +203,7 @@ async fn missing_code_mode_wait_traces_only_the_wait_tool_call() -> anyhow::Resu
let (mut session, turn) = make_session_and_context().await;
attach_test_trace(&mut session, &turn, temp.path())?;
let registry = ToolRegistry::with_handler_for_test(
codex_tools::ToolName::plain(WAIT_TOOL_NAME),
Arc::new(CodeModeWaitHandler),
);
let registry = ToolRegistry::with_handler_for_test(Arc::new(CodeModeWaitHandler));
let session = Arc::new(session);
let turn = Arc::new(turn);
-30
View File
@@ -1,36 +1,6 @@
load("@rules_cc//cc:defs.bzl", "cc_library")
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "linux-sandbox",
crate_name = "codex_linux_sandbox",
# Bazel wires vendored bubblewrap + libcap via :vendored-bwrap-ffi below
# and sets vendored_bwrap_available explicitly, so we skip Cargo's
# build.rs in Bazel builds.
build_script_enabled = False,
deps_extra = select({
"@platforms//os:linux": [":vendored-bwrap-ffi"],
"//conditions:default": [],
}),
rustc_flags_extra = select({
"@platforms//os:linux": ["--cfg=vendored_bwrap_available"],
"//conditions:default": [],
}),
)
cc_library(
name = "vendored-bwrap-ffi",
srcs = ["//codex-rs/vendor:bubblewrap_c_sources"],
hdrs = [
"config.h",
"//codex-rs/vendor:bubblewrap_headers",
],
copts = [
"-D_GNU_SOURCE",
"-Dmain=bwrap_main",
],
includes = ["."],
deps = ["@libcap//:libcap"],
target_compatible_with = ["@platforms//os:linux"],
visibility = ["//visibility:private"],
)
+1 -4
View File
@@ -27,6 +27,7 @@ libc = { workspace = true }
seccompiler = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
url = { workspace = true }
[target.'cfg(target_os = "linux")'.dev-dependencies]
@@ -40,7 +41,3 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
[build-dependencies]
cc = "1"
pkg-config = "0.3"
+5 -5
View File
@@ -12,10 +12,10 @@ 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,
the helper falls back to the vendored bubblewrap path compiled into this
binary.
the helper falls back to the bundled `codex-resources/bwrap` binary shipped
with Codex.
Codex also surfaces a startup warning when `bwrap` is missing so users know it
is falling back to the vendored helper. Codex surfaces the same startup warning
is falling back to the bundled helper. Codex surfaces the same startup warning
path when bubblewrap cannot create user namespaces. WSL2 follows the normal
Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing because
it cannot create the required user namespaces, so Codex rejects sandboxed shell
@@ -28,8 +28,8 @@ commands that would enter the bubblewrap path.
helper uses it.
- If `bwrap` is present but too old to support `--argv0`, the helper uses a
no-`--argv0` compatibility path for the inner re-exec.
- If `bwrap` is missing, the helper falls back to the vendored bubblewrap
path.
- If `bwrap` is missing, the helper falls back to the bundled
`codex-resources/bwrap` path.
- If `bwrap` is missing, Codex also surfaces a startup warning instead of
printing directly from the sandbox helper.
- If bubblewrap cannot create user namespaces, Codex surfaces a startup warning
+1 -109
View File
@@ -1,111 +1,3 @@
use std::env;
use std::path::Path;
use std::path::PathBuf;
fn main() {
// Tell rustc/clippy that this is an expected cfg value.
println!("cargo:rustc-check-cfg=cfg(vendored_bwrap_available)");
println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR");
println!("cargo:rerun-if-env-changed=CODEX_SKIP_VENDORED_BWRAP");
// Rebuild if the vendored bwrap sources change.
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default());
let vendor_dir = manifest_dir.join("../vendor/bubblewrap");
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join("bubblewrap.c").display()
);
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join("bind-mount.c").display()
);
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join("network.c").display()
);
println!(
"cargo:rerun-if-changed={}",
vendor_dir.join("utils.c").display()
);
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "linux" || env::var_os("CODEX_SKIP_VENDORED_BWRAP").is_some() {
return;
}
if let Err(err) = try_build_vendored_bwrap() {
panic!("failed to compile vendored bubblewrap for Linux target: {err}");
}
}
fn try_build_vendored_bwrap() -> Result<(), String> {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?);
let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?);
let src_dir = resolve_bwrap_source_dir(&manifest_dir)?;
let libcap = pkg_config::Config::new()
.probe("libcap")
.map_err(|err| format!("libcap not available via pkg-config: {err}"))?;
let config_h = out_dir.join("config.h");
std::fs::write(
&config_h,
r#"#pragma once
#define PACKAGE_STRING "bubblewrap built at codex build-time"
"#,
)
.map_err(|err| format!("failed to write {}: {err}", config_h.display()))?;
let mut build = cc::Build::new();
build
.file(src_dir.join("bubblewrap.c"))
.file(src_dir.join("bind-mount.c"))
.file(src_dir.join("network.c"))
.file(src_dir.join("utils.c"))
.include(&out_dir)
.include(&src_dir)
.define("_GNU_SOURCE", None)
// Rename `main` so we can call it via FFI.
.define("main", Some("bwrap_main"));
for include_path in libcap.include_paths {
// Use -idirafter so target sysroot headers win (musl cross builds),
// while still allowing libcap headers from the host toolchain.
build.flag(format!("-idirafter{}", include_path.display()));
}
build.compile("build_time_bwrap");
println!("cargo:rustc-cfg=vendored_bwrap_available");
Ok(())
}
/// Resolve the bubblewrap source directory used for build-time compilation.
///
/// Priority:
/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout.
/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`.
fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result<PathBuf, String> {
if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") {
let src_dir = PathBuf::from(path);
if src_dir.exists() {
return Ok(src_dir);
}
return Err(format!(
"CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}",
src_dir.display()
));
}
let vendor_dir = manifest_dir.join("../vendor/bubblewrap");
if vendor_dir.exists() {
return Ok(vendor_dir);
}
Err(format!(
"expected vendored bubblewrap at {}, but it was not found.\n\
Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.",
vendor_dir.display()
))
println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SHA256");
}
-3
View File
@@ -1,3 +0,0 @@
#pragma once
#define PACKAGE_STRING "bubblewrap built at codex build-time"
+304 -25
View File
@@ -1,20 +1,28 @@
use std::ffi::CString;
use std::fs::File;
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::raw::c_char;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use crate::vendored_bwrap::exec_vendored_bwrap;
use codex_sandboxing::find_system_bwrap_in_path;
use codex_utils_absolute_path::AbsolutePathBuf;
use sha2::Digest as _;
use sha2::Sha256;
const SHA256_HEX_LEN: usize = 64;
const NULL_SHA256_DIGEST: [u8; 32] = [0; 32];
#[derive(Debug, Clone, PartialEq, Eq)]
enum BubblewrapLauncher {
System(SystemBwrapLauncher),
Vendored,
Bundled(BundledBwrapLauncher),
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -23,6 +31,11 @@ struct SystemBwrapLauncher {
supports_argv0: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BundledBwrapLauncher {
program: AbsolutePathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SystemBwrapCapabilities {
supports_argv0: bool,
@@ -34,30 +47,46 @@ pub(crate) fn exec_bwrap(argv: Vec<String>, preserved_files: Vec<File>) -> ! {
BubblewrapLauncher::System(launcher) => {
exec_system_bwrap(&launcher.program, argv, preserved_files)
}
BubblewrapLauncher::Vendored => exec_vendored_bwrap(argv, preserved_files),
BubblewrapLauncher::Bundled(launcher) => {
exec_bundled_bwrap(&launcher.program, argv, preserved_files)
}
BubblewrapLauncher::Unavailable => {
panic!(
"bubblewrap is unavailable: no system bwrap was found on PATH and no bundled \
codex-resources/bwrap binary was found next to the Codex executable"
)
}
}
}
fn preferred_bwrap_launcher() -> BubblewrapLauncher {
static LAUNCHER: OnceLock<BubblewrapLauncher> = OnceLock::new();
LAUNCHER
.get_or_init(|| match find_system_bwrap_in_path() {
Some(path) => preferred_bwrap_launcher_for_path(&path),
None => BubblewrapLauncher::Vendored,
.get_or_init(|| {
if let Some(path) = find_system_bwrap_in_path()
&& let Some(launcher) = system_bwrap_launcher_for_path(&path)
{
return BubblewrapLauncher::System(launcher);
}
match bundled_bwrap_launcher() {
Some(launcher) => BubblewrapLauncher::Bundled(launcher),
None => BubblewrapLauncher::Unavailable,
}
})
.clone()
}
fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher {
preferred_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities)
fn system_bwrap_launcher_for_path(system_bwrap_path: &Path) -> Option<SystemBwrapLauncher> {
system_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities)
}
fn preferred_bwrap_launcher_for_path_with_probe(
fn system_bwrap_launcher_for_path_with_probe(
system_bwrap_path: &Path,
system_bwrap_capabilities: impl FnOnce(&Path) -> Option<SystemBwrapCapabilities>,
) -> BubblewrapLauncher {
) -> Option<SystemBwrapLauncher> {
if !system_bwrap_path.is_file() {
return BubblewrapLauncher::Vendored;
return None;
}
let Some(SystemBwrapCapabilities {
@@ -65,7 +94,7 @@ fn preferred_bwrap_launcher_for_path_with_probe(
supports_perms: true,
}) = system_bwrap_capabilities(system_bwrap_path)
else {
return BubblewrapLauncher::Vendored;
return None;
};
let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) {
Ok(path) => path,
@@ -74,7 +103,7 @@ fn preferred_bwrap_launcher_for_path_with_probe(
system_bwrap_path.display()
),
};
BubblewrapLauncher::System(SystemBwrapLauncher {
Some(SystemBwrapLauncher {
program: system_bwrap_path,
supports_argv0,
})
@@ -83,10 +112,50 @@ fn preferred_bwrap_launcher_for_path_with_probe(
pub(crate) fn preferred_bwrap_supports_argv0() -> bool {
match preferred_bwrap_launcher() {
BubblewrapLauncher::System(launcher) => launcher.supports_argv0,
BubblewrapLauncher::Vendored => true,
BubblewrapLauncher::Bundled(_) | BubblewrapLauncher::Unavailable => true,
}
}
fn bundled_bwrap_launcher() -> Option<BundledBwrapLauncher> {
let current_exe = std::env::current_exe().ok()?;
find_bundled_bwrap_for_exe(&current_exe).map(|program| BundledBwrapLauncher { program })
}
fn find_bundled_bwrap_for_exe(exe: &Path) -> Option<AbsolutePathBuf> {
bundled_bwrap_candidates_for_exe(exe)
.into_iter()
.find(|candidate| is_executable_file(candidate))
.map(|path| {
AbsolutePathBuf::from_absolute_path(&path).unwrap_or_else(|err| {
panic!(
"failed to normalize bundled bubblewrap path {}: {err}",
path.display()
)
})
})
}
fn bundled_bwrap_candidates_for_exe(exe: &Path) -> Vec<PathBuf> {
let Some(exe_dir) = exe.parent() else {
return Vec::new();
};
let mut candidates = Vec::new();
candidates.push(exe_dir.join("codex-resources").join("bwrap"));
if let Some(package_target_dir) = exe_dir.parent() {
candidates.push(package_target_dir.join("codex-resources").join("bwrap"));
}
candidates.push(exe_dir.join("bwrap"));
candidates
}
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
fn system_bwrap_capabilities(system_bwrap_path: &Path) -> Option<SystemBwrapCapabilities> {
// bubblewrap added `--argv0` in v0.9.0:
// https://github.com/containers/bubblewrap/releases/tag/v0.9.0
@@ -117,7 +186,7 @@ fn exec_system_bwrap(
let program = CString::new(program.as_path().as_os_str().as_bytes())
.unwrap_or_else(|err| panic!("invalid system bubblewrap path: {err}"));
let cstrings = argv_to_cstrings(&argv);
let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect();
let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(CString::as_ptr).collect();
argv_ptrs.push(std::ptr::null());
// SAFETY: `program` and every entry in `argv_ptrs` are valid C strings for
@@ -129,6 +198,124 @@ fn exec_system_bwrap(
panic!("failed to exec system bubblewrap {program_path}: {err}");
}
fn exec_bundled_bwrap(
program: &AbsolutePathBuf,
argv: Vec<String>,
preserved_files: Vec<File>,
) -> ! {
let bwrap_file = File::open(program.as_path()).unwrap_or_else(|err| {
panic!(
"failed to open bundled bubblewrap {}: {err}",
program.as_path().display()
)
});
verify_bundled_bwrap_digest(
&bwrap_file,
expected_bundled_bwrap_sha256(),
program.as_path(),
)
.unwrap_or_else(|err| panic!("{err}"));
make_files_inheritable(&preserved_files);
let fd_path = format!("/proc/self/fd/{}", bwrap_file.as_raw_fd());
let program_cstring = CString::new(fd_path.as_str())
.unwrap_or_else(|err| panic!("invalid bundled bubblewrap fd path: {err}"));
let cstrings = argv_to_cstrings(&argv);
let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(CString::as_ptr).collect();
argv_ptrs.push(std::ptr::null());
// SAFETY: `program_cstring` and every entry in `argv_ptrs` are valid C
// strings for the duration of the call. On success `execv` does not return.
unsafe {
libc::execv(program_cstring.as_ptr(), argv_ptrs.as_ptr());
}
let err = std::io::Error::last_os_error();
panic!(
"failed to exec bundled bubblewrap {} via {fd_path}: {err}",
program.as_path().display()
);
}
fn expected_bundled_bwrap_sha256() -> Option<[u8; 32]> {
static EXPECTED: OnceLock<Option<[u8; 32]>> = OnceLock::new();
*EXPECTED.get_or_init(|| {
let Some(raw_digest) = option_env!("CODEX_BWRAP_SHA256") else {
return None;
};
let digest = parse_sha256_hex(raw_digest)
.unwrap_or_else(|err| panic!("invalid CODEX_BWRAP_SHA256 value: {err}"));
(digest != NULL_SHA256_DIGEST).then_some(digest)
})
}
fn verify_bundled_bwrap_digest(
file: &File,
expected: Option<[u8; 32]>,
path: &Path,
) -> Result<(), String> {
let Some(expected) = expected else {
return Ok(());
};
let mut file = file
.try_clone()
.map_err(|err| format!("failed to clone bundled bubblewrap fd: {err}"))?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 8192];
loop {
let read = file.read(&mut buffer).map_err(|err| {
format!(
"failed to read bundled bubblewrap {} for digest verification: {err}",
path.display()
)
})?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
let actual: [u8; 32] = hasher.finalize().into();
if actual == expected {
return Ok(());
}
Err(format!(
"bundled bubblewrap digest mismatch for {}: expected sha256:{}, got sha256:{}",
path.display(),
bytes_to_hex(&expected),
bytes_to_hex(&actual),
))
}
fn parse_sha256_hex(raw: &str) -> Result<[u8; 32], String> {
if raw.len() != SHA256_HEX_LEN {
return Err(format!(
"expected {SHA256_HEX_LEN} hex characters, got {}",
raw.len()
));
}
let mut digest = [0_u8; 32];
for (index, byte) in digest.iter_mut().enumerate() {
let start = index * 2;
*byte = u8::from_str_radix(&raw[start..start + 2], 16)
.map_err(|err| format!("invalid hex byte at offset {start}: {err}"))?;
}
Ok(digest)
}
fn bytes_to_hex(bytes: &[u8; 32]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut hex = String::with_capacity(SHA256_HEX_LEN);
for byte in bytes {
hex.push(HEX[(byte >> 4) as usize] as char);
hex.push(HEX[(byte & 0x0f) as usize] as char);
}
hex
}
fn argv_to_cstrings(argv: &[String]) -> Vec<CString> {
let mut cstrings: Vec<CString> = Vec::with_capacity(argv.len());
for arg in argv {
@@ -170,7 +357,10 @@ fn clear_cloexec(fd: libc::c_int) {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::NamedTempFile;
use tempfile::tempdir;
#[test]
fn prefers_system_bwrap_when_help_lists_argv0() {
@@ -179,13 +369,13 @@ mod tests {
let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute");
assert_eq!(
preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| {
system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| {
Some(SystemBwrapCapabilities {
supports_argv0: true,
supports_perms: true,
})
}),
BubblewrapLauncher::System(SystemBwrapLauncher {
Some(SystemBwrapLauncher {
program: expected,
supports_argv0: true,
})
@@ -198,13 +388,13 @@ mod tests {
let fake_bwrap_path = fake_bwrap.path();
assert_eq!(
preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| {
system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| {
Some(SystemBwrapCapabilities {
supports_argv0: false,
supports_perms: true,
})
}),
BubblewrapLauncher::System(SystemBwrapLauncher {
Some(SystemBwrapLauncher {
program: AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"),
supports_argv0: false,
})
@@ -212,28 +402,108 @@ mod tests {
}
#[test]
fn falls_back_to_vendored_when_system_bwrap_lacks_perms() {
fn ignores_system_bwrap_when_system_bwrap_lacks_perms() {
let fake_bwrap = NamedTempFile::new().expect("temp file");
assert_eq!(
preferred_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| {
system_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| {
Some(SystemBwrapCapabilities {
supports_argv0: false,
supports_perms: false,
})
}),
BubblewrapLauncher::Vendored
None
);
}
#[test]
fn falls_back_to_vendored_when_system_bwrap_is_missing() {
fn ignores_system_bwrap_when_system_bwrap_is_missing() {
assert_eq!(
preferred_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")),
BubblewrapLauncher::Vendored
system_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")),
None
);
}
#[test]
fn finds_standalone_bundled_bwrap_next_to_exe_resources() {
let temp_dir = tempdir().expect("temp dir");
let exe = temp_dir.path().join("codex");
let expected_bwrap = temp_dir.path().join("codex-resources").join("bwrap");
write_executable(&exe);
write_executable(&expected_bwrap);
assert_eq!(
find_bundled_bwrap_for_exe(&exe),
Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute"))
);
}
#[test]
fn finds_npm_bundled_bwrap_next_to_target_vendor_dir() {
let temp_dir = tempdir().expect("temp dir");
let target_dir = temp_dir.path().join("vendor/x86_64-unknown-linux-musl");
let exe = target_dir.join("codex").join("codex");
let expected_bwrap = target_dir.join("codex-resources").join("bwrap");
write_executable(&exe);
write_executable(&expected_bwrap);
assert_eq!(
find_bundled_bwrap_for_exe(&exe),
Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute"))
);
}
#[test]
fn finds_adjacent_dev_bwrap() {
let temp_dir = tempdir().expect("temp dir");
let exe = temp_dir.path().join("codex");
let expected_bwrap = temp_dir.path().join("bwrap");
write_executable(&exe);
write_executable(&expected_bwrap);
assert_eq!(
find_bundled_bwrap_for_exe(&exe),
Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute"))
);
}
#[test]
fn bundled_digest_verification_skips_missing_expected_digest() {
let file = NamedTempFile::new().expect("temp file");
fs::write(file.path(), b"contents").expect("write file");
verify_bundled_bwrap_digest(file.as_file(), /*expected*/ None, file.path())
.expect("missing digest should skip verification");
}
#[test]
fn bundled_digest_verification_accepts_matching_digest() {
let file = NamedTempFile::new().expect("temp file");
fs::write(file.path(), b"contents").expect("write file");
let expected: [u8; 32] = Sha256::digest(b"contents").into();
verify_bundled_bwrap_digest(file.as_file(), Some(expected), file.path())
.expect("matching digest should verify");
}
#[test]
fn bundled_digest_verification_rejects_mismatched_digest() {
let file = NamedTempFile::new().expect("temp file");
fs::write(file.path(), b"contents").expect("write file");
let err = verify_bundled_bwrap_digest(file.as_file(), Some([0xab; 32]), file.path())
.expect_err("mismatched digest should fail");
assert!(err.contains("bundled bubblewrap digest mismatch"));
}
#[test]
fn parses_sha256_hex_digest() {
assert_eq!(parse_sha256_hex(&"ab".repeat(32)), Ok([0xab; 32]));
assert_eq!(parse_sha256_hex(&"00".repeat(32)), Ok(NULL_SHA256_DIGEST));
assert!(parse_sha256_hex("ab").is_err());
assert!(parse_sha256_hex(&format!("{}xx", "00".repeat(31))).is_err());
}
#[test]
fn preserved_files_are_made_inheritable_for_system_exec() {
let file = NamedTempFile::new().expect("temp file");
@@ -244,6 +514,15 @@ mod tests {
assert_eq!(fd_flags(file.as_file().as_raw_fd()) & libc::FD_CLOEXEC, 0);
}
fn write_executable(path: &Path) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent dir");
}
fs::write(path, b"").expect("write executable");
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.expect("set executable permissions");
}
fn set_cloexec(fd: libc::c_int) {
let flags = fd_flags(fd);
// SAFETY: `fd` is valid for the duration of the test.
-2
View File
@@ -13,8 +13,6 @@ mod launcher;
mod linux_run_main;
#[cfg(target_os = "linux")]
mod proxy_routing;
#[cfg(target_os = "linux")]
mod vendored_bwrap;
#[cfg(target_os = "linux")]
pub fn run_main() -> ! {
+21 -11
View File
@@ -25,6 +25,7 @@ use crate::launcher::exec_bwrap;
use crate::launcher::preferred_bwrap_supports_argv0;
use crate::proxy_routing::activate_proxy_routes_in_netns;
use crate::proxy_routing::prepare_host_proxy_route_spec;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::NetworkSandboxPolicy;
@@ -333,6 +334,7 @@ fn run_bwrap_with_proc_fallback(
file_system_sandbox_policy,
network_mode,
)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err))
{
// Keep the retry silent so sandbox-internal diagnostics do not leak into the
// child process stderr stream.
@@ -350,7 +352,8 @@ fn run_bwrap_with_proc_fallback(
sandbox_policy_cwd,
command_cwd,
options,
);
)
.unwrap_or_else(|err| exit_with_bwrap_build_error(err));
apply_inner_command_argv0(&mut bwrap_args.args);
run_or_exec_bwrap(bwrap_args);
}
@@ -374,24 +377,28 @@ fn build_bwrap_argv(
sandbox_policy_cwd: &Path,
command_cwd: &Path,
options: BwrapOptions,
) -> crate::bwrap::BwrapArgs {
) -> CodexResult<crate::bwrap::BwrapArgs> {
let bwrap_args = create_bwrap_command_args(
inner,
file_system_sandbox_policy,
sandbox_policy_cwd,
command_cwd,
options,
)
.unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}"));
)?;
let mut argv = vec!["bwrap".to_string()];
argv.extend(bwrap_args.args);
crate::bwrap::BwrapArgs {
Ok(crate::bwrap::BwrapArgs {
args: argv,
preserved_files: bwrap_args.preserved_files,
synthetic_mount_targets: bwrap_args.synthetic_mount_targets,
protected_create_targets: bwrap_args.protected_create_targets,
}
})
}
fn exit_with_bwrap_build_error(err: codex_protocol::error::CodexErr) -> ! {
eprintln!("error building bubblewrap command: {err}");
std::process::exit(1);
}
fn apply_inner_command_argv0(argv: &mut Vec<String>) {
@@ -439,15 +446,15 @@ fn preflight_proc_mount_support(
command_cwd: &Path,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_mode: BwrapNetworkMode,
) -> bool {
) -> CodexResult<bool> {
let preflight_argv = build_preflight_bwrap_argv(
sandbox_policy_cwd,
command_cwd,
file_system_sandbox_policy,
network_mode,
);
)?;
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
!is_proc_mount_failure(stderr.as_str())
Ok(!is_proc_mount_failure(stderr.as_str()))
}
fn build_preflight_bwrap_argv(
@@ -455,7 +462,7 @@ fn build_preflight_bwrap_argv(
command_cwd: &Path,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_mode: BwrapNetworkMode,
) -> crate::bwrap::BwrapArgs {
) -> CodexResult<crate::bwrap::BwrapArgs> {
let preflight_command = vec![resolve_true_command()];
build_bwrap_argv(
preflight_command,
@@ -1235,7 +1242,10 @@ fn synthetic_mount_marker_dir(path: &Path) -> PathBuf {
}
fn synthetic_mount_registry_root() -> PathBuf {
std::env::temp_dir().join("codex-bwrap-synthetic-mount-targets")
let effective_uid = unsafe { libc::geteuid() };
std::env::temp_dir().join(format!(
"codex-bwrap-synthetic-mount-targets-{effective_uid}"
))
}
fn hash_path(path: &Path) -> u64 {
@@ -61,6 +61,7 @@ fn inserts_bwrap_argv0_before_command_separator() {
..Default::default()
},
)
.expect("build bwrap argv")
.args;
apply_inner_command_argv0_for_launcher(
&mut argv,
@@ -104,6 +105,7 @@ fn rewrites_inner_command_path_when_bwrap_lacks_argv0() {
..Default::default()
},
)
.expect("build bwrap argv")
.args;
apply_inner_command_argv0_for_launcher(
&mut argv,
@@ -172,6 +174,7 @@ fn inserts_unshare_net_when_network_isolation_requested() {
..Default::default()
},
)
.expect("build bwrap argv")
.args;
assert!(argv.contains(&"--unshare-net".to_string()));
}
@@ -190,6 +193,7 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
..Default::default()
},
)
.expect("build bwrap argv")
.args;
assert!(argv.contains(&"--unshare-net".to_string()));
}
@@ -265,6 +269,7 @@ fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() {
&FileSystemSandboxPolicy::unrestricted(),
mode,
)
.expect("build preflight argv")
.args;
assert!(argv.iter().any(|arg| arg == "--"));
}
@@ -297,6 +302,17 @@ fn cleanup_synthetic_mount_targets_removes_only_empty_mount_targets() {
assert!(!missing_file.exists());
}
#[test]
fn synthetic_mount_registry_root_is_unique_to_effective_user() {
let effective_uid = unsafe { libc::geteuid() };
assert_eq!(
synthetic_mount_registry_root(),
std::env::temp_dir().join(format!(
"codex-bwrap-synthetic-mount-targets-{effective_uid}"
))
);
}
#[test]
fn cleanup_synthetic_mount_targets_waits_for_other_active_registrations() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
@@ -1,78 +0,0 @@
//! Build-time bubblewrap entrypoint.
//!
//! On Linux targets, the build script compiles bubblewrap's C sources and
//! exposes a `bwrap_main` symbol that we can call via FFI.
#[cfg(vendored_bwrap_available)]
mod imp {
use std::ffi::CString;
use std::fs::File;
use std::os::raw::c_char;
unsafe extern "C" {
fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int;
}
fn argv_to_cstrings(argv: &[String]) -> Vec<CString> {
let mut cstrings: Vec<CString> = Vec::with_capacity(argv.len());
for arg in argv {
match CString::new(arg.as_str()) {
Ok(value) => cstrings.push(value),
Err(err) => panic!("failed to convert argv to CString: {err}"),
}
}
cstrings
}
/// Run the build-time bubblewrap `main` function and return its exit code.
///
/// On success, bubblewrap will `execve` into the target program and this
/// function will never return. A return value therefore implies failure.
pub(crate) fn run_vendored_bwrap_main(
argv: &[String],
_preserved_files: &[File],
) -> libc::c_int {
let cstrings = argv_to_cstrings(argv);
let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect();
argv_ptrs.push(std::ptr::null());
// SAFETY: We provide a null-terminated argv vector whose pointers
// remain valid for the duration of the call.
unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) }
}
/// Execute the build-time bubblewrap `main` function with the given argv.
pub(crate) fn exec_vendored_bwrap(argv: Vec<String>, preserved_files: Vec<File>) -> ! {
let exit_code = run_vendored_bwrap_main(&argv, &preserved_files);
std::process::exit(exit_code);
}
}
#[cfg(not(vendored_bwrap_available))]
mod imp {
use std::fs::File;
/// Panics with a clear error when the build-time bwrap path is not enabled.
pub(crate) fn run_vendored_bwrap_main(
_argv: &[String],
_preserved_files: &[File],
) -> libc::c_int {
panic!(
r#"build-time bubblewrap is not available in this build.
codex-linux-sandbox should always compile vendored bubblewrap on Linux targets.
Notes:
- ensure the target OS is Linux
- libcap headers must be available via pkg-config
- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"#
);
}
/// Panics with a clear error when the build-time bwrap path is not enabled.
pub(crate) fn exec_vendored_bwrap(_argv: Vec<String>, _preserved_files: Vec<File>) -> ! {
let _ = run_vendored_bwrap_main(&[], &[]);
unreachable!("run_vendored_bwrap_main should always panic in this configuration")
}
}
pub(crate) use imp::exec_vendored_bwrap;
+54 -1
View File
@@ -40,7 +40,7 @@ const NETWORK_TIMEOUT_MS: u64 = 10_000;
#[cfg(target_arch = "aarch64")]
const NETWORK_TIMEOUT_MS: u64 = 10_000;
const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build.";
const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found";
fn create_env_from_core_vars() -> HashMap<String, String> {
let policy = ShellEnvironmentPolicy::default();
@@ -587,6 +587,59 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() {
assert_ne!(codex_output.exit_code, 0);
}
#[tokio::test]
async fn sandbox_reports_codex_symlink_build_failure_without_panicking() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}
use std::os::unix::fs::symlink;
let tmpdir = tempfile::tempdir().expect("tempdir");
let decoy = tmpdir.path().join("decoy-codex");
std::fs::create_dir_all(&decoy).expect("create decoy dir");
let dot_codex = tmpdir.path().join(".codex");
symlink(&decoy, &dot_codex).expect("create .codex symlink");
let output = match run_cmd_result_with_writable_roots(
&["bash", "-lc", "true"],
&[tmpdir.path().to_path_buf()],
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await
{
Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output,
result => panic!(".codex symlink build failure should deny: {result:?}"),
};
assert_eq!(output.exit_code, 1);
assert!(
output
.stderr
.text
.contains("error building bubblewrap command:"),
"stderr: {}",
output.stderr.text
);
assert!(
output
.stderr
.text
.contains("cannot enforce sandbox read-only path"),
"stderr: {}",
output.stderr.text
);
assert!(
!output.stderr.text.contains("panicked at"),
"stderr: {}",
output.stderr.text
);
}
#[tokio::test]
async fn sandbox_keeps_parent_repo_discovery_while_blocking_child_metadata() {
if should_skip_bwrap_tests().await {
@@ -15,7 +15,7 @@ use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build.";
const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found";
const NETWORK_TIMEOUT_MS: u64 = 4_000;
const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[
"loopback: Failed RTM_NEWADDR",
@@ -82,7 +82,7 @@ fn is_managed_proxy_permission_error(stderr: &str) -> bool {
async fn managed_proxy_skip_reason() -> Option<String> {
if should_skip_bwrap_tests().await {
return Some("vendored bwrap was not built in this environment".to_string());
return Some("bubblewrap is unavailable in this environment".to_string());
}
let mut env = create_env_from_core_vars();
+1 -1
View File
@@ -18,7 +18,7 @@ const MISSING_BWRAP_WARNING: &str = concat!(
"Install bubblewrap with your OS package manager. ",
"See the sandbox prerequisites: ",
"https://developers.openai.com/codex/concepts/sandboxing#prerequisites. ",
"Codex will use the vendored bubblewrap in the meantime.",
"Codex will use the bundled bubblewrap in the meantime.",
);
const USER_NAMESPACE_WARNING: &str =
"Codex's Linux sandbox uses bubblewrap and needs access to create user namespaces.";
@@ -40,6 +40,7 @@ use codex_core_api::Permissions;
use codex_core_api::ProjectConfig;
use codex_core_api::RealtimeAudioConfig;
use codex_core_api::RealtimeConfig;
use codex_core_api::SessionPickerViewMode;
use codex_core_api::SessionSource;
use codex_core_api::ShellEnvironmentPolicy;
use codex_core_api::TerminalResizeReflowConfig;
@@ -200,6 +201,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
tui_raw_output_mode: false,
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
tui_keymap: TuiKeymap::default(),
tui_session_picker_view: SessionPickerViewMode::Dense,
tui_vim_mode_default: false,
cwd,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
+18 -12
View File
@@ -172,8 +172,8 @@ pub fn build_tool_registry_plan(
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("exec_command", ToolHandlerKind::UnifiedExec);
plan.register_handler("write_stdin", ToolHandlerKind::UnifiedExec);
plan.register_handler("exec_command", ToolHandlerKind::ExecCommand);
plan.register_handler("write_stdin", ToolHandlerKind::WriteStdin);
}
ConfigShellToolType::Disabled => {}
ConfigShellToolType::ShellCommand => {
@@ -193,8 +193,8 @@ pub fn build_tool_registry_plan(
&& config.shell_type != ConfigShellToolType::Disabled
{
plan.register_handler("shell", ToolHandlerKind::Shell);
plan.register_handler("container.exec", ToolHandlerKind::Shell);
plan.register_handler("local_shell", ToolHandlerKind::Shell);
plan.register_handler("container.exec", ToolHandlerKind::ContainerExec);
plan.register_handler("local_shell", ToolHandlerKind::LocalShell);
plan.register_handler("shell_command", ToolHandlerKind::ShellCommand);
}
@@ -214,9 +214,12 @@ pub fn build_tool_registry_plan(
/*supports_parallel_tool_calls*/ true,
config.code_mode_enabled,
);
plan.register_handler("list_mcp_resources", ToolHandlerKind::McpResource);
plan.register_handler("list_mcp_resource_templates", ToolHandlerKind::McpResource);
plan.register_handler("read_mcp_resource", ToolHandlerKind::McpResource);
plan.register_handler("list_mcp_resources", ToolHandlerKind::ListMcpResources);
plan.register_handler(
"list_mcp_resource_templates",
ToolHandlerKind::ListMcpResourceTemplates,
);
plan.register_handler("read_mcp_resource", ToolHandlerKind::ReadMcpResource);
}
plan.push_spec(
@@ -231,19 +234,19 @@ pub fn build_tool_registry_plan(
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("get_goal", ToolHandlerKind::Goal);
plan.register_handler("get_goal", ToolHandlerKind::GetGoal);
plan.push_spec(
create_create_goal_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("create_goal", ToolHandlerKind::Goal);
plan.register_handler("create_goal", ToolHandlerKind::CreateGoal);
plan.push_spec(
create_update_goal_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("update_goal", ToolHandlerKind::Goal);
plan.register_handler("update_goal", ToolHandlerKind::UpdateGoal);
}
plan.push_spec(
@@ -493,14 +496,17 @@ pub fn build_tool_registry_plan(
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("spawn_agents_on_csv", ToolHandlerKind::AgentJobs);
plan.register_handler("spawn_agents_on_csv", ToolHandlerKind::SpawnAgentsOnCsv);
if config.agent_jobs_worker_tools {
plan.push_spec(
create_report_agent_job_result_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler("report_agent_job_result", ToolHandlerKind::AgentJobs);
plan.register_handler(
"report_agent_job_result",
ToolHandlerKind::ReportAgentJobResult,
);
}
}
+13 -5
View File
@@ -10,19 +10,26 @@ use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolHandlerKind {
AgentJobs,
ApplyPatch,
CloseAgentV1,
CloseAgentV2,
CodeModeExecute,
CodeModeWait,
ContainerExec,
CreateGoal,
DynamicTool,
ExecCommand,
FollowupTaskV2,
Goal,
GetGoal,
ListAgentsV2,
ListMcpResourceTemplates,
ListMcpResources,
LocalShell,
Mcp,
McpResource,
Plan,
ReadMcpResource,
ReportAgentJobResult,
RequestPluginInstall,
RequestPermissions,
RequestUserInput,
ResumeAgentV1,
@@ -30,15 +37,16 @@ pub enum ToolHandlerKind {
SendMessageV2,
Shell,
ShellCommand,
SpawnAgentsOnCsv,
SpawnAgentV1,
SpawnAgentV2,
TestSync,
ToolSearch,
RequestPluginInstall,
UnifiedExec,
UpdateGoal,
ViewImage,
WaitAgentV1,
WaitAgentV2,
WriteStdin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+8 -4
View File
@@ -77,7 +77,7 @@ impl App {
return Ok(AppRunControl::Continue);
}
};
match crate::resume_picker::run_resume_picker_with_app_server(
match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server(
tui,
&self.config,
/*show_all*/ false,
@@ -97,9 +97,13 @@ impl App {
}
}
}
SessionSelection::Exit
| SessionSelection::StartFresh
| SessionSelection::Fork(_) => {}
SessionSelection::Exit | SessionSelection::StartFresh => {
self.refresh_in_memory_config_from_disk_best_effort(
"closing the session picker",
)
.await;
}
SessionSelection::Fork(_) => {}
}
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
+17 -8
View File
@@ -328,16 +328,25 @@ impl ChatWidget {
SlashCommand::Diff => {
self.add_diff_in_progress();
let tx = self.app_event_tx.clone();
let runner = self.workspace_command_runner.clone();
let cwd = self
.current_cwd
.clone()
.unwrap_or_else(|| self.config.cwd.to_path_buf());
tokio::spawn(async move {
let text = match get_git_diff().await {
Ok((is_git_repo, diff_text)) => {
if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
let text = match runner {
Some(runner) => match get_git_diff(runner.as_ref(), &cwd).await {
Ok((is_git_repo, diff_text)) => {
if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
}
}
}
Err(e) => format!("Failed to compute diff: {e}"),
Err(e) => format!("Failed to compute diff: {e}"),
},
None => "Failed to compute diff: workspace command runner unavailable"
.to_string(),
};
tx.send(AppEvent::DiffResult(text));
});
+281 -61
View File
@@ -5,25 +5,32 @@
//! untracked files. When the current directory is not inside a Git
//! repository, the function returns `Ok((false, String::new()))`.
use std::io;
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
use std::time::Duration;
use crate::workspace_command::WorkspaceCommand;
use crate::workspace_command::WorkspaceCommandExecutor;
use crate::workspace_command::WorkspaceCommandOutput;
const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
/// Return value of [`get_git_diff`].
///
/// * `bool` – Whether the current working directory is inside a Git repo.
/// * `String` – The concatenated diff (may be empty).
pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> {
pub(crate) async fn get_git_diff(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
) -> Result<(bool, String), String> {
// First check if we are inside a Git repository.
if !inside_git_repo().await? {
if !inside_git_repo(runner, cwd).await? {
return Ok((false, String::new()));
}
// Run tracked diff and untracked file listing in parallel.
let (tracked_diff_res, untracked_output_res) = tokio::join!(
run_git_capture_diff(&["diff", "--color"]),
run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"]),
run_git_capture_diff(runner, cwd, &["diff", "--color"]),
run_git_capture_stdout(runner, cwd, &["ls-files", "--others", "--exclude-standard"]),
);
let tracked_diff = tracked_diff_res?;
let untracked_output = untracked_output_res?;
@@ -35,27 +42,15 @@ pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> {
Path::new("/dev/null")
};
let null_path = null_device.to_str().unwrap_or("/dev/null").to_string();
let mut join_set: tokio::task::JoinSet<io::Result<String>> = tokio::task::JoinSet::new();
let null_path = null_device.to_str().unwrap_or("/dev/null");
for file in untracked_output
.split('\n')
.map(str::trim)
.filter(|s| !s.is_empty())
{
let null_path = null_path.clone();
let file = file.to_string();
join_set.spawn(async move {
let args = ["diff", "--color", "--no-index", "--", &null_path, &file];
run_git_capture_diff(&args).await
});
}
while let Some(res) = join_set.join_next().await {
match res {
Ok(Ok(diff)) => untracked_diff.push_str(&diff),
Ok(Err(err)) if err.kind() == io::ErrorKind::NotFound => {}
Ok(Err(err)) => return Err(err),
Err(_) => {}
}
let args = ["diff", "--color", "--no-index", "--", null_path, file];
let diff = run_git_capture_diff(runner, cwd, &args).await?;
untracked_diff.push_str(&diff);
}
Ok((true, format!("{tracked_diff}{untracked_diff}")))
@@ -63,57 +58,282 @@ pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> {
/// Helper that executes `git` with the given `args` and returns `stdout` as a
/// UTF-8 string. Any non-zero exit status is considered an *error*.
async fn run_git_capture_stdout(args: &[&str]) -> io::Result<String> {
let output = Command::new("git")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
async fn run_git_capture_stdout(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
args: &[&str],
) -> Result<String, String> {
let output = run_git_command(runner, cwd, args).await?;
if output.success() {
Ok(output.stdout)
} else {
Err(io::Error::other(format!(
Err(format!(
"git {:?} failed with status {}",
args, output.status
)))
args, output.exit_code
))
}
}
/// Like [`run_git_capture_stdout`] but treats exit status 1 as success and
/// returns stdout. Git returns 1 for diffs when differences are present.
async fn run_git_capture_diff(args: &[&str]) -> io::Result<String> {
let output = Command::new("git")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await?;
if output.status.success() || output.status.code() == Some(1) {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
async fn run_git_capture_diff(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
args: &[&str],
) -> Result<String, String> {
let output = run_git_command(runner, cwd, args).await?;
if output.success() || output.exit_code == 1 {
Ok(output.stdout)
} else {
Err(io::Error::other(format!(
Err(format!(
"git {:?} failed with status {}",
args, output.status
)))
args, output.exit_code
))
}
}
/// Determine if the current directory is inside a Git repository.
async fn inside_git_repo() -> io::Result<bool> {
let status = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
async fn inside_git_repo(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
) -> Result<bool, String> {
let output = run_git_command(runner, cwd, &["rev-parse", "--is-inside-work-tree"]).await?;
Ok(output.success())
}
match status {
Ok(s) if s.success() => Ok(true),
Ok(_) => Ok(false),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), // git not installed
Err(e) => Err(e),
async fn run_git_command(
runner: &dyn WorkspaceCommandExecutor,
cwd: &Path,
args: &[&str],
) -> Result<WorkspaceCommandOutput, String> {
let mut argv = Vec::with_capacity(args.len() + 1);
argv.push("git".to_string());
argv.extend(args.iter().map(|arg| (*arg).to_string()));
runner
.run(
WorkspaceCommand::new(argv)
.cwd(cwd.to_path_buf())
.timeout(DIFF_COMMAND_TIMEOUT)
.disable_output_cap(),
)
.await
.map_err(|err| err.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace_command::WorkspaceCommandError;
use pretty_assertions::assert_eq;
use std::collections::VecDeque;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Mutex;
#[tokio::test]
async fn get_git_diff_returns_not_git_for_non_git_cwd() {
let cwd = PathBuf::from("/workspace");
let runner = FakeRunner::new(vec![response(
&["git", "rev-parse", "--is-inside-work-tree"],
/*exit_code*/ 128,
"",
)]);
let result = get_git_diff(&runner, &cwd).await;
assert_eq!(result, Ok((false, String::new())));
assert_commands(
&runner.commands(),
&[&["git", "rev-parse", "--is-inside-work-tree"]],
&cwd,
);
}
#[tokio::test]
async fn get_git_diff_concatenates_tracked_and_untracked_diffs() {
let cwd = PathBuf::from("/workspace");
let runner = FakeRunner::new(vec![
response(
&["git", "rev-parse", "--is-inside-work-tree"],
/*exit_code*/ 0,
"true\n",
),
response(
&["git", "diff", "--color"],
/*exit_code*/ 1,
"tracked\n",
),
response(
&["git", "ls-files", "--others", "--exclude-standard"],
/*exit_code*/ 0,
"new.txt\n",
),
response(
&[
"git",
"diff",
"--color",
"--no-index",
"--",
null_device(),
"new.txt",
],
/*exit_code*/ 1,
"untracked\n",
),
]);
let result = get_git_diff(&runner, &cwd).await;
assert_eq!(result, Ok((true, "tracked\nuntracked\n".to_string())));
assert_commands(
&runner.commands(),
&[
&["git", "rev-parse", "--is-inside-work-tree"],
&["git", "diff", "--color"],
&["git", "ls-files", "--others", "--exclude-standard"],
&[
"git",
"diff",
"--color",
"--no-index",
"--",
null_device(),
"new.txt",
],
],
&cwd,
);
}
#[tokio::test]
async fn get_git_diff_accepts_diff_exit_code_one() {
let cwd = PathBuf::from("/workspace");
let runner = FakeRunner::new(vec![
response(
&["git", "rev-parse", "--is-inside-work-tree"],
/*exit_code*/ 0,
"true\n",
),
response(
&["git", "diff", "--color"],
/*exit_code*/ 1,
"tracked\n",
),
response(
&["git", "ls-files", "--others", "--exclude-standard"],
/*exit_code*/ 0,
"",
),
]);
let result = get_git_diff(&runner, &cwd).await;
assert_eq!(result, Ok((true, "tracked\n".to_string())));
}
#[tokio::test]
async fn get_git_diff_rejects_unexpected_git_diff_status() {
let cwd = PathBuf::from("/workspace");
let runner = FakeRunner::new(vec![
response(
&["git", "rev-parse", "--is-inside-work-tree"],
/*exit_code*/ 0,
"true\n",
),
response(&["git", "diff", "--color"], /*exit_code*/ 2, ""),
response(
&["git", "ls-files", "--others", "--exclude-standard"],
/*exit_code*/ 0,
"",
),
]);
let error = get_git_diff(&runner, &cwd)
.await
.expect_err("unexpected git diff status should fail");
assert!(
error.contains("git [\"diff\", \"--color\"] failed with status 2"),
"unexpected error: {error}",
);
}
fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse {
FakeResponse {
argv: argv.iter().map(|arg| (*arg).to_string()).collect(),
output: WorkspaceCommandOutput {
exit_code,
stdout: stdout.to_string(),
stderr: String::new(),
},
}
}
fn null_device() -> &'static str {
if cfg!(windows) { "NUL" } else { "/dev/null" }
}
fn assert_commands(commands: &[WorkspaceCommand], expected: &[&[&str]], cwd: &Path) {
let actual: Vec<Vec<String>> = commands
.iter()
.map(|command| command.argv.clone())
.collect();
let expected: Vec<Vec<String>> = expected
.iter()
.map(|argv| argv.iter().map(|arg| (*arg).to_string()).collect())
.collect();
assert_eq!(actual, expected);
for command in commands {
assert_eq!(command.cwd.as_deref(), Some(cwd));
assert_eq!(command.timeout, DIFF_COMMAND_TIMEOUT);
assert!(command.disable_output_cap);
}
}
struct FakeResponse {
argv: Vec<String>,
output: WorkspaceCommandOutput,
}
struct FakeRunner {
responses: Mutex<VecDeque<FakeResponse>>,
commands: Mutex<Vec<WorkspaceCommand>>,
}
impl FakeRunner {
fn new(responses: Vec<FakeResponse>) -> Self {
Self {
responses: Mutex::new(responses.into()),
commands: Mutex::new(Vec::new()),
}
}
fn commands(&self) -> Vec<WorkspaceCommand> {
self.commands.lock().expect("commands lock").clone()
}
}
impl WorkspaceCommandExecutor for FakeRunner {
fn run(
&self,
command: WorkspaceCommand,
) -> Pin<
Box<
dyn Future<Output = Result<WorkspaceCommandOutput, WorkspaceCommandError>>
+ Send
+ '_,
>,
> {
Box::pin(async move {
let mut responses = self.responses.lock().expect("responses lock");
let response = responses.pop_front().expect("missing fake response");
assert_eq!(command.argv, response.argv);
self.commands.lock().expect("commands lock").push(command);
Ok(response.output)
})
}
}
}
+13
View File
@@ -1430,6 +1430,11 @@ async fn run_ratatui_app(
None => None,
};
let picker_cancelled_without_selection = matches!(
session_selection,
resume_picker::SessionSelection::StartFresh
) && (cli.resume_picker || cli.fork_picker);
let mut config = match &session_selection {
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
load_config_or_exit_with_fallback_cwd(
@@ -1440,6 +1445,14 @@ async fn run_ratatui_app(
)
.await
}
resume_picker::SessionSelection::StartFresh if picker_cancelled_without_selection => {
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
)
.await
}
_ => config,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,214 @@
use std::sync::Arc;
use crate::app_server_session::AppServerSession;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::ReasoningSummaryCell;
use crate::history_cell::UserHistoryCell;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_protocol::ThreadId;
use codex_protocol::items::UserMessageItem;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RawReasoningVisibility {
Hidden,
Visible,
}
pub(crate) async fn load_session_transcript(
app_server: &mut AppServerSession,
thread_id: ThreadId,
raw_reasoning_visibility: RawReasoningVisibility,
) -> std::io::Result<TranscriptCells> {
let thread = app_server
.thread_read(thread_id, /*include_turns*/ true)
.await
.map_err(std::io::Error::other)?;
Ok(thread_to_transcript_cells(
&thread,
raw_reasoning_visibility,
))
}
pub(crate) fn thread_to_transcript_cells(
thread: &Thread,
raw_reasoning_visibility: RawReasoningVisibility,
) -> TranscriptCells {
let cwd = thread.cwd.as_path();
let mut cells: TranscriptCells = Vec::new();
for item in thread.turns.iter().flat_map(|turn| turn.items.iter()) {
match item {
ThreadItem::UserMessage { id, content } => {
let item = UserMessageItem {
id: id.clone(),
content: content
.iter()
.cloned()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
};
cells.push(Arc::new(UserHistoryCell {
message: item.message(),
text_elements: item.text_elements(),
local_image_paths: item.local_image_paths(),
remote_image_urls: item.image_urls(),
}));
}
ThreadItem::AgentMessage { text, .. } => {
if !text.trim().is_empty() {
cells.push(Arc::new(AgentMarkdownCell::new(text.clone(), cwd)));
}
}
ThreadItem::Plan { text, .. } => {
if !text.trim().is_empty() {
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
text.clone(),
cwd,
)));
}
}
ThreadItem::Reasoning {
summary, content, ..
} => {
let text = if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
&& !content.is_empty()
{
content.join("\n\n")
} else {
summary.join("\n\n")
};
if !text.trim().is_empty() {
cells.push(Arc::new(ReasoningSummaryCell::new(
"Reasoning".to_string(),
text,
cwd,
/*transcript_only*/ false,
)));
}
}
other => {
if let Some(cell) = fallback_transcript_cell(other) {
cells.push(Arc::new(cell));
}
}
}
}
if cells.is_empty() {
cells.push(Arc::new(PlainHistoryCell::new(vec![
"No transcript content available".italic().dim().into(),
])));
}
cells
}
fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
let lines = match item {
ThreadItem::HookPrompt { fragments, .. } => fragments
.iter()
.map(|fragment| {
vec![
"hook prompt: ".dim(),
fragment.text.trim().to_string().into(),
]
.into()
})
.collect::<Vec<_>>(),
ThreadItem::CommandExecution {
command,
status,
aggregated_output,
exit_code,
..
} => {
let mut lines: Vec<Line<'static>> =
vec![vec!["$ ".dim(), command.clone().into()].into()];
lines.push(
format!(
"status: {status:?}{}",
exit_code
.map(|code| format!(" · exit {code}"))
.unwrap_or_default()
)
.dim()
.into(),
);
if let Some(output) = aggregated_output.as_deref()
&& !output.trim().is_empty()
{
lines.extend(
output
.lines()
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
);
}
lines
}
ThreadItem::FileChange {
changes, status, ..
} => vec![
format!("file changes: {status:?} · {} changes", changes.len())
.dim()
.into(),
],
ThreadItem::McpToolCall {
server,
tool,
status,
..
} => vec![
format!("mcp tool: {server}/{tool} · {status:?}")
.dim()
.into(),
],
ThreadItem::DynamicToolCall {
namespace,
tool,
status,
..
} => {
let name = namespace
.as_ref()
.map(|namespace| format!("{namespace}/{tool}"))
.unwrap_or_else(|| tool.clone());
vec![format!("tool: {name} · {status:?}").dim().into()]
}
ThreadItem::CollabAgentToolCall { tool, status, .. } => {
vec![format!("agent tool: {tool:?} · {status:?}").dim().into()]
}
ThreadItem::WebSearch { query, .. } => {
vec![vec!["web search: ".dim(), query.clone().into()].into()]
}
ThreadItem::ImageView { path, .. } => {
vec![format!("image: {}", path.as_path().display()).dim().into()]
}
ThreadItem::ImageGeneration {
status, saved_path, ..
} => {
let saved = saved_path
.as_ref()
.map(|path| format!(" · {}", path.as_path().display()))
.unwrap_or_default();
vec![format!("image generation: {status}{saved}").dim().into()]
}
ThreadItem::EnteredReviewMode { review, .. } => {
vec![vec!["review started: ".dim(), review.clone().into()].into()]
}
ThreadItem::ExitedReviewMode { review, .. } => {
vec![vec!["review finished: ".dim(), review.clone().into()].into()]
}
ThreadItem::ContextCompaction { .. } => {
vec!["context compacted".dim().into()]
}
ThreadItem::UserMessage { .. }
| ThreadItem::AgentMessage { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. } => return None,
};
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
}
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(true, None, 120,)"
---
❯ 15m ago Propose session picker redesign with enough title text to exercise truncation
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(true, None, 100,)"
---
❯ 15m ago Propose session picker redesign with enough title text to exercise truncation
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(true, None, 48,)"
---
❯ 15m ago Propose session picker redesig...
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(false,\nSome(PathBuf::from(\"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs\")),\n100,)"
---
❯ 15m ago Propose session picker redesign with enough title text to exercise truncation
@@ -0,0 +1,5 @@
---
source: tui/src/resume_picker.rs
expression: "render_dense_row_snapshot(true, None, 48,)"
---
❯ 15m ago Propose session picker redesig...
@@ -0,0 +1,6 @@
---
source: tui/src/resume_picker.rs
expression: terminal.backend().to_string()
---
15m ago First dense row
❯ 15m ago Second dense row
@@ -0,0 +1,14 @@
---
source: tui/src/resume_picker.rs
expression: rendered
---
⌄ Investigate picker expansion
│ Session: 019dabc1-0ef5-7431-b81c-03037f51f62c
│ Created: 1 hour ago · 2026-04-28 16:30:00
│ Updated: 15 minutes ago · 2026-04-28 17:45:00
│ Directory: /tmp/codex
│ Branch:  fcoury/session-picker
│
│ Conversation:
│ Show me the recent transcript
└ Here are the last few lines.
@@ -0,0 +1,7 @@
---
source: tui/src/resume_picker.rs
expression: "footer_snapshot(&state, 96, 20)"
---
───────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─
enter resume esc clear ctrl+c quit tab focus ←/→ option
ctrl+o comfy ctrl+t preview ctrl+e exp ↑/↓ browse
@@ -0,0 +1,7 @@
---
source: tui/src/resume_picker.rs
expression: "footer_snapshot(&state, 220, 20)"
---
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─
enter resume esc start new ctrl+c quit tab focus sort/filter ←/→ change option
ctrl+o dense view ctrl+t transcript ctrl+e expand ↑/↓ browse

Some files were not shown because too many files have changed in this diff Show More