mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Merge 95686245c8 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+10
-2
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(¶ms.thread_id)
|
||||
&& self
|
||||
@@ -2353,7 +2377,15 @@ impl ThreadRequestProcessor {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
match self.resume_running_thread(&request_id, ¶ms).await {
|
||||
match self
|
||||
.resume_running_thread(
|
||||
&request_id,
|
||||
¶ms,
|
||||
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(¶ms.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"))
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
#define PACKAGE_STRING "bubblewrap built for Codex"
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¶ms.command)),
|
||||
ToolPayload::LocalShell { params } => Some(codex_shell_command::parse_command::shlex_join(
|
||||
¶ms.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(¶ms.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(
|
||||
¶ms.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(¶ms.command))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
ToolPayload::LocalShell { params } => !is_known_safe_command(¶ms.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(¶ms.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(¶ms, 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(¶ms.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(¶ms.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(¶ms, 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(¶ms.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(¶ms.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(¶ms, 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(¶ms.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(¶ms, 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(¶ms.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(¶ms, 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(¶ms.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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#define PACKAGE_STRING "bubblewrap built at codex build-time"
|
||||
@@ -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(¤t_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.
|
||||
|
||||
@@ -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() -> ! {
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
+4527
-567
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))
|
||||
}
|
||||
+5
@@ -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
|
||||
+5
@@ -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
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "render_dense_row_snapshot(true, None, 48,)"
|
||||
---
|
||||
❯ 15m ago Propose session picker redesig...
|
||||
+5
@@ -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
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: "render_dense_row_snapshot(true, None, 48,)"
|
||||
---
|
||||
❯ 15m ago Propose session picker redesig...
|
||||
+6
@@ -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
|
||||
+14
@@ -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.
|
||||
+7
@@ -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
|
||||
+7
@@ -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
Reference in New Issue
Block a user