diff --git a/.github/scripts/install-musl-build-tools.sh b/.github/scripts/install-musl-build-tools.sh new file mode 100644 index 0000000000..634fe04d71 --- /dev/null +++ b/.github/scripts/install-musl-build-tools.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TARGET:?TARGET environment variable is required}" +: "${GITHUB_ENV:?GITHUB_ENV environment variable is required}" + +apt_update_args=() +if [[ -n "${APT_UPDATE_ARGS:-}" ]]; then + # shellcheck disable=SC2206 + apt_update_args=(${APT_UPDATE_ARGS}) +fi + +apt_install_args=() +if [[ -n "${APT_INSTALL_ARGS:-}" ]]; then + # shellcheck disable=SC2206 + apt_install_args=(${APT_INSTALL_ARGS}) +fi + +sudo apt-get update "${apt_update_args[@]}" +sudo apt-get install -y "${apt_install_args[@]}" musl-tools pkg-config g++ clang libc++-dev libc++abi-dev lld + +case "${TARGET}" in + x86_64-unknown-linux-musl) + arch="x86_64" + ;; + aarch64-unknown-linux-musl) + arch="aarch64" + ;; + *) + echo "Unexpected musl target: ${TARGET}" >&2 + exit 1 + ;; +esac + +# Use the musl toolchain as the Rust linker to avoid Zig injecting its own CRT. +if command -v "${arch}-linux-musl-gcc" >/dev/null; then + musl_linker="$(command -v "${arch}-linux-musl-gcc")" +elif command -v musl-gcc >/dev/null; then + musl_linker="$(command -v musl-gcc)" +else + echo "musl gcc not found after install; arch=${arch}" >&2 + exit 1 +fi + +zig_target="${TARGET/-unknown-linux-musl/-linux-musl}" +runner_temp="${RUNNER_TEMP:-/tmp}" +tool_root="${runner_temp}/codex-musl-tools-${TARGET}" +mkdir -p "${tool_root}" + +sysroot="" +if command -v zig >/dev/null; then + zig_bin="$(command -v zig)" + cc="${tool_root}/zigcc" + cxx="${tool_root}/zigcxx" + + cat >"${cc}" <"${cxx}" </dev/null || true)" +else + cc="${musl_linker}" + + if command -v "${arch}-linux-musl-g++" >/dev/null; then + cxx="$(command -v "${arch}-linux-musl-g++")" + elif command -v musl-g++ >/dev/null; then + cxx="$(command -v musl-g++)" + else + cxx="${cc}" + fi +fi + +if [[ -n "${sysroot}" && "${sysroot}" != "/" ]]; then + echo "BORING_BSSL_SYSROOT=${sysroot}" >> "$GITHUB_ENV" + boring_sysroot_var="BORING_BSSL_SYSROOT_${TARGET}" + boring_sysroot_var="${boring_sysroot_var//-/_}" + echo "${boring_sysroot_var}=${sysroot}" >> "$GITHUB_ENV" +fi + +cflags="-pthread" +cxxflags="-pthread" +if [[ "${TARGET}" == "aarch64-unknown-linux-musl" ]]; then + # BoringSSL enables -Wframe-larger-than=25344 under clang and treats warnings as errors. + cflags="${cflags} -Wno-error=frame-larger-than" + cxxflags="${cxxflags} -Wno-error=frame-larger-than" +fi + +echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" +echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" +echo "CC=${cc}" >> "$GITHUB_ENV" +echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" +target_cc_var="CC_${TARGET}" +target_cc_var="${target_cc_var//-/_}" +echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" +echo "CXX=${cxx}" >> "$GITHUB_ENV" +echo "TARGET_CXX=${cxx}" >> "$GITHUB_ENV" +target_cxx_var="CXX_${TARGET}" +target_cxx_var="${target_cxx_var//-/_}" +echo "${target_cxx_var}=${cxx}" >> "$GITHUB_ENV" + +cargo_linker_var="CARGO_TARGET_${TARGET^^}_LINKER" +cargo_linker_var="${cargo_linker_var//-/_}" +echo "${cargo_linker_var}=${musl_linker}" >> "$GITHUB_ENV" + +echo "CMAKE_C_COMPILER=${cc}" >> "$GITHUB_ENV" +echo "CMAKE_CXX_COMPILER=${cxx}" >> "$GITHUB_ENV" +echo "CMAKE_ARGS=-DCMAKE_HAVE_THREADS_LIBRARY=1 -DCMAKE_USE_PTHREADS_INIT=1 -DCMAKE_THREAD_LIBS_INIT=-pthread -DTHREADS_PREFER_PTHREAD_FLAG=ON" >> "$GITHUB_ENV" diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 4f97d2de02..55e05251d7 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -81,7 +81,7 @@ jobs: # previously built artifacts to minimize build time. The more precise you are with # hashFiles sources the less work bazel will have to do. # - name: Mount bazel caches - # uses: actions/cache@v4 + # uses: actions/cache@v5 # with: # path: | # ~/.cache/bazel-repo-cache diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 22a1a8320e..a8efec1089 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -261,15 +261,21 @@ jobs: /var/cache/apt key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools env: DEBIAN_FRONTEND: noninteractive + TARGET: ${{ matrix.target }} + APT_UPDATE_ARGS: -o Acquire::Retries=3 + APT_INSTALL_ARGS: --no-install-recommends shell: bash - run: | - set -euo pipefail - sudo apt-get -y update -o Acquire::Retries=3 - sudo apt-get -y install --no-install-recommends musl-tools pkg-config + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Install cargo-chef if: ${{ matrix.profile == 'release' }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index f058f8dbef..3625c830dd 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -104,11 +104,17 @@ jobs: ${{ github.workspace }}/codex-rs/target/ key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools - run: | - sudo apt-get update - sudo apt-get install -y musl-tools pkg-config + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Cargo build shell: bash @@ -285,7 +291,28 @@ jobs: # Must run from inside the dest dir so 7z won't # embed the directory path inside the zip. if [[ "${{ matrix.runner }}" == windows* ]]; then - (cd "$dest" && 7z a "${base}.zip" "$base") + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + (cd "$bundle_dir" && 7z a "$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi fi # Also create .zst (existing behaviour) *and* remove the original diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 62a4989b77..a8a01b5f6a 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -97,11 +97,17 @@ jobs: with: targets: ${{ matrix.target }} + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + - if: ${{ matrix.install_musl }} name: Install musl build dependencies - run: | - sudo apt-get update - sudo apt-get install -y musl-tools pkg-config + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Build exec server binaries run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper diff --git a/README.md b/README.md index eb4ace74f2..733c915d43 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

npm i -g @openai/codex
or brew install --cask codex

+

curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash
or brew install --cask codex
or npm i -g @openai/codex

Codex CLI is a coding agent from OpenAI that runs locally on your computer.

Codex CLI splash @@ -15,6 +15,13 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), , } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -698,6 +702,7 @@ pub enum SessionSource { VsCode, Exec, AppServer, + SubAgent(CoreSubAgentSource), #[serde(other)] Unknown, } @@ -709,7 +714,7 @@ impl From for SessionSource { CoreSessionSource::VSCode => SessionSource::VsCode, CoreSessionSource::Exec => SessionSource::Exec, CoreSessionSource::Mcp => SessionSource::AppServer, - CoreSessionSource::SubAgent(_) => SessionSource::Unknown, + CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), CoreSessionSource::Unknown => SessionSource::Unknown, } } @@ -722,6 +727,7 @@ impl From for CoreSessionSource { SessionSource::VsCode => CoreSessionSource::VSCode, SessionSource::Exec => CoreSessionSource::Exec, SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), SessionSource::Unknown => CoreSessionSource::Unknown, } } @@ -932,7 +938,7 @@ pub struct CollaborationModeListParams {} #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CollaborationModeListResponse { - pub data: Vec, + pub data: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1081,6 +1087,7 @@ pub struct ThreadStartParams { pub base_instructions: Option, pub developer_instructions: Option, pub personality: Option, + pub ephemeral: Option, /// If true, opt into emitting raw response items on the event stream. /// /// This is for internal use only (e.g. Codex Cloud). @@ -1464,7 +1471,7 @@ pub struct Thread { #[ts(type = "number")] pub updated_at: i64, /// [UNSTABLE] Path to the thread on disk. - pub path: PathBuf, + pub path: Option, /// Working directory captured for the thread. pub cwd: PathBuf, /// Version of the CLI that created the thread. @@ -2501,6 +2508,24 @@ pub struct DeprecationNoticeNotification { pub details: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextPosition { + /// 1-based line number. + pub line: usize, + /// 1-based column number (in Unicode scalar values). + pub column: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2509,6 +2534,14 @@ pub struct ConfigWarningNotification { pub summary: String, /// Optional extra guidance or error details. pub details: Option, + /// Optional path to the config file that triggered the warning. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub path: Option, + /// Optional range for the error location inside the config file. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub range: Option, } #[cfg(test)] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index f31df68b3f..9fa2044058 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -35,6 +35,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } mcp-types = { workspace = true } tempfile = { workspace = true } +time = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = [ "io-std", diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 931c1073aa..68e233b843 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1006,7 +1006,15 @@ pub(crate) async fn apply_bespoke_event_handling( }; if let Some(request_id) = pending { - let rollout_path = conversation.rollout_path(); + let Some(rollout_path) = conversation.rollout_path() else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "thread has no persisted rollout".to_string(), + data: None, + }; + outgoing.send_error(request_id, error).await; + return; + }; let response = match read_summary_from_rollout( rollout_path.as_path(), fallback_model_provider.as_str(), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index daf0fb3d73..d0b8c7f949 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -134,6 +134,7 @@ use codex_core::InitialHistory; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; +use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; use codex_core::ThreadSortKey as CoreThreadSortKey; use codex_core::auth::CLIENT_ID; @@ -172,6 +173,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::McpServerRefreshConfig; @@ -192,7 +194,6 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; -use tokio::select; use tokio::sync::Mutex; use tokio::sync::broadcast; use tokio::sync::oneshot; @@ -1378,11 +1379,23 @@ impl CodexMessageProcessor { session_configured, .. } = new_thread; + let rollout_path = match session_configured.rollout_path { + Some(path) => path, + None => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "rollout path missing for v1 conversation".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; let response = NewConversationResponse { conversation_id: thread_id, model: session_configured.model, reasoning_effort: session_configured.reasoning_effort, - rollout_path: session_configured.rollout_path, + rollout_path, }; self.outgoing.send_response(request_id, response).await; } @@ -1398,7 +1411,7 @@ impl CodexMessageProcessor { } async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) { - let typesafe_overrides = self.build_thread_config_overrides( + let mut typesafe_overrides = self.build_thread_config_overrides( params.model, params.model_provider, params.cwd, @@ -1408,6 +1421,7 @@ impl CodexMessageProcessor { params.developer_instructions, params.personality, ); + typesafe_overrides.ephemeral = Some(params.ephemeral.unwrap_or_default()); let config = match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides) @@ -1429,50 +1443,45 @@ impl CodexMessageProcessor { Ok(new_conv) => { let NewThread { thread_id, + thread, session_configured, .. } = new_conv; - let rollout_path = session_configured.rollout_path.clone(); + let config_snapshot = thread.config_snapshot().await; let fallback_provider = self.config.model_provider_id.as_str(); // A bit hacky, but the summary contains a lot of useful information for the thread // that unfortunately does not get returned from thread_manager.start_thread(). - let thread = match read_summary_from_rollout( - rollout_path.as_path(), - fallback_provider, - ) - .await - { - Ok(summary) => summary_to_thread(summary), - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; - return; + let thread = match session_configured.rollout_path.as_ref() { + Some(rollout_path) => { + match read_summary_from_rollout(rollout_path.as_path(), fallback_provider) + .await + { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_id}: {err}", + rollout_path.display() + ), + ) + .await; + return; + } + } } + None => build_ephemeral_thread(thread_id, &config_snapshot), }; - let SessionConfiguredEvent { - model, - model_provider_id, - cwd, - approval_policy, - sandbox_policy, - .. - } = session_configured; let response = ThreadStartResponse { thread: thread.clone(), - model, - model_provider: model_provider_id, - cwd, - approval_policy: approval_policy.into(), - sandbox: sandbox_policy.into(), - reasoning_effort: session_configured.reasoning_effort, + model: config_snapshot.model, + model_provider: config_snapshot.model_provider_id, + cwd: config_snapshot.cwd, + approval_policy: config_snapshot.approval_policy.into(), + sandbox: config_snapshot.sandbox_policy.into(), + reasoning_effort: config_snapshot.reasoning_effort, }; // Auto-attach a thread listener when starting a thread. @@ -1725,7 +1734,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } - async fn thread_read(&self, request_id: RequestId, params: ThreadReadParams) { + async fn thread_read(&mut self, request_id: RequestId, params: ThreadReadParams) { let ThreadReadParams { thread_id, include_turns, @@ -1744,15 +1753,8 @@ impl CodexMessageProcessor { match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string()) .await { - Ok(Some(path)) => path, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for thread id {thread_uuid}"), - ) - .await; - return; - } + Ok(Some(path)) => Some(path), + Ok(None) => None, Err(err) => { self.send_invalid_request_error( request_id, @@ -1763,24 +1765,45 @@ impl CodexMessageProcessor { } }; - let fallback_provider = self.config.model_provider_id.as_str(); - let mut thread = match read_summary_from_rollout(&rollout_path, fallback_provider).await { - Ok(summary) => summary_to_thread(summary), - Err(err) => { - self.send_internal_error( + let mut thread = if let Some(rollout_path) = rollout_path.as_ref() { + let fallback_provider = self.config.model_provider_id.as_str(); + match read_summary_from_rollout(rollout_path, fallback_provider).await { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_uuid}: {err}", + rollout_path.display() + ), + ) + .await; + return; + } + } + } else { + let Ok(thread) = self.thread_manager.get_thread(thread_uuid).await else { + self.send_invalid_request_error( request_id, - format!( - "failed to load rollout `{}` for thread {thread_uuid}: {err}", - rollout_path.display() - ), + format!("thread not loaded: {thread_uuid}"), + ) + .await; + return; + }; + let config_snapshot = thread.config_snapshot().await; + if include_turns { + self.send_invalid_request_error( + request_id, + "ephemeral threads do not support includeTurns".to_string(), ) .await; return; } + build_ephemeral_thread(thread_uuid, &config_snapshot) }; - if include_turns { - match read_event_msgs_from_rollout(&rollout_path).await { + if include_turns && let Some(rollout_path) = rollout_path.as_ref() { + match read_event_msgs_from_rollout(rollout_path).await { Ok(events) => { thread.turns = build_turns_from_event_msgs(&events); } @@ -1967,6 +1990,14 @@ impl CodexMessageProcessor { initial_messages, .. } = session_configured; + let Some(rollout_path) = rollout_path else { + self.send_internal_error( + request_id, + format!("rollout path missing for thread {thread_id}"), + ) + .await; + return; + }; // Auto-attach a thread listener when resuming a thread. if let Err(err) = self .attach_conversation_listener(thread_id, false, ApiVersion::V2) @@ -2170,6 +2201,14 @@ impl CodexMessageProcessor { initial_messages, .. } = session_configured; + let Some(rollout_path) = rollout_path else { + self.send_internal_error( + request_id, + format!("rollout path missing for thread {thread_id}"), + ) + .await; + return; + }; // Auto-attach a conversation listener when forking a thread. if let Err(err) = self .attach_conversation_listener(thread_id, false, ApiVersion::V2) @@ -2919,6 +2958,18 @@ impl CodexMessageProcessor { session_configured, .. }) => { + let rollout_path = match session_configured.rollout_path.clone() { + Some(path) => path, + None => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "rollout path missing for resumed conversation".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; self.outgoing .send_server_notification(ServerNotification::SessionConfigured( SessionConfiguredNotification { @@ -2928,7 +2979,7 @@ impl CodexMessageProcessor { history_log_id: session_configured.history_log_id, history_entry_count: session_configured.history_entry_count, initial_messages: session_configured.initial_messages.clone(), - rollout_path: session_configured.rollout_path.clone(), + rollout_path: rollout_path.clone(), }, )) .await; @@ -2941,7 +2992,7 @@ impl CodexMessageProcessor { conversation_id: thread_id, model: session_configured.model.clone(), initial_messages, - rollout_path: session_configured.rollout_path.clone(), + rollout_path, }; self.outgoing.send_response(request_id, response).await; } @@ -3117,6 +3168,19 @@ impl CodexMessageProcessor { } }; + let rollout_path = match session_configured.rollout_path.clone() { + Some(path) => path, + None => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "rollout path missing for forked conversation".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + self.outgoing .send_server_notification(ServerNotification::SessionConfigured( SessionConfiguredNotification { @@ -3126,7 +3190,7 @@ impl CodexMessageProcessor { history_log_id: session_configured.history_log_id, history_entry_count: session_configured.history_entry_count, initial_messages: session_configured.initial_messages.clone(), - rollout_path: session_configured.rollout_path.clone(), + rollout_path: rollout_path.clone(), }, )) .await; @@ -3139,7 +3203,7 @@ impl CodexMessageProcessor { conversation_id: thread_id, model: session_configured.model.clone(), initial_messages, - rollout_path: session_configured.rollout_path.clone(), + rollout_path, }; self.outgoing.send_response(request_id, response).await; } @@ -3250,51 +3314,27 @@ impl CodexMessageProcessor { // If the thread is active, request shutdown and wait briefly. if let Some(conversation) = self.thread_manager.remove_thread(&thread_id).await { info!("thread {thread_id} was active; shutting down"); - let conversation_clone = conversation.clone(); - let notify = Arc::new(tokio::sync::Notify::new()); - let notify_clone = notify.clone(); - - // Establish the listener for ShutdownComplete before submitting - // Shutdown so it is not missed. - let is_shutdown = tokio::spawn(async move { - // Create the notified future outside the loop to avoid losing notifications. - let notified = notify_clone.notified(); - tokio::pin!(notified); - loop { - select! { - _ = &mut notified => { break; } - event = conversation_clone.next_event() => { - match event { - Ok(event) => { - if matches!(event.msg, EventMsg::ShutdownComplete) { break; } - } - // Break on errors to avoid tight loops when the agent loop has exited. - Err(_) => { break; } - } - } - } - } - }); // Request shutdown. match conversation.submit(Op::Shutdown).await { Ok(_) => { - // Successfully submitted Shutdown; wait before proceeding. - select! { - _ = is_shutdown => { - // Normal shutdown: proceed with archive. - } - _ = tokio::time::sleep(Duration::from_secs(10)) => { - warn!("thread {thread_id} shutdown timed out; proceeding with archive"); - // Wake any waiter; use notify_waiters to avoid missing the signal. - notify.notify_waiters(); - // Perhaps we lost a shutdown race, so let's continue to - // clean up the .jsonl file. + // Poll agent status rather than consuming events so attached listeners do not block shutdown. + let wait_for_shutdown = async { + loop { + if matches!(conversation.agent_status().await, AgentStatus::Shutdown) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; } + }; + if tokio::time::timeout(Duration::from_secs(10), wait_for_shutdown) + .await + .is_err() + { + warn!("thread {thread_id} shutdown timed out; proceeding with archive"); } } Err(err) => { error!("failed to submit Shutdown to thread {thread_id}: {err}"); - notify.notify_waiters(); } } } @@ -3809,23 +3849,29 @@ impl CodexMessageProcessor { ); } - let rollout_path = review_thread.rollout_path(); let fallback_provider = self.config.model_provider_id.as_str(); - match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await { - Ok(summary) => { - let thread = summary_to_thread(summary); - let notif = ThreadStartedNotification { thread }; - self.outgoing - .send_server_notification(ServerNotification::ThreadStarted(notif)) - .await; - } - Err(err) => { - tracing::warn!( - "failed to load summary for review thread {}: {}", - session_configured.session_id, - err - ); + if let Some(rollout_path) = review_thread.rollout_path() { + match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await { + Ok(summary) => { + let thread = summary_to_thread(summary); + let notif = ThreadStartedNotification { thread }; + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + } + Err(err) => { + tracing::warn!( + "failed to load summary for review thread {}: {}", + session_configured.session_id, + err + ); + } } + } else { + tracing::warn!( + "review thread {} has no rollout path", + session_configured.session_id + ); } let turn_id = review_thread @@ -4228,7 +4274,7 @@ impl CodexMessageProcessor { async fn resolve_rollout_path(&self, conversation_id: ThreadId) -> Option { match self.thread_manager.get_thread(conversation_id).await { - Ok(conv) => Some(conv.rollout_path()), + Ok(conv) => conv.rollout_path(), Err(_) => None, } } @@ -4493,6 +4539,23 @@ async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option Thread { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + Thread { + id: thread_id.to_string(), + preview: String::new(), + model_provider: config_snapshot.model_provider_id.clone(), + created_at: now, + updated_at: now, + path: None, + cwd: config_snapshot.cwd.clone(), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + source: config_snapshot.session_source.clone().into(), + git_info: None, + turns: Vec::new(), + } +} + pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { let ConversationSummary { conversation_id, @@ -4521,7 +4584,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), - path, + path: Some(path), cwd, cli_version, source: source.into(), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 417ed46f93..fde208106d 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -3,6 +3,7 @@ use codex_common::CliConfigOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; +use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::LoaderOverrides; use std::io::ErrorKind; use std::io::Result as IoResult; @@ -11,9 +12,15 @@ use std::path::PathBuf; use crate::message_processor::MessageProcessor; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::TextPosition as AppTextPosition; +use codex_app_server_protocol::TextRange as AppTextRange; +use codex_core::ExecPolicyError; use codex_core::check_execpolicy_for_warnings; +use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::TextRange as CoreTextRange; use codex_feedback::CodexFeedback; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; @@ -44,6 +51,112 @@ mod outgoing_message; /// plenty for an interactive CLI. const CHANNEL_CAPACITY: usize = 128; +fn config_warning_from_error( + summary: impl Into, + err: &std::io::Error, +) -> ConfigWarningNotification { + let (path, range) = match config_error_location(err) { + Some((path, range)) => (Some(path), Some(range)), + None => (None, None), + }; + ConfigWarningNotification { + summary: summary.into(), + details: Some(err.to_string()), + path, + range, + } +} + +fn config_error_location(err: &std::io::Error) -> Option<(String, AppTextRange)> { + err.get_ref() + .and_then(|err| err.downcast_ref::()) + .map(|err| { + let config_error = err.config_error(); + ( + config_error.path.to_string_lossy().to_string(), + app_text_range(&config_error.range), + ) + }) +} + +fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option, Option) { + match err { + ExecPolicyError::ParsePolicy { path, source } => { + if let Some(location) = source.location() { + let range = AppTextRange { + start: AppTextPosition { + line: location.range.start.line, + column: location.range.start.column, + }, + end: AppTextPosition { + line: location.range.end.line, + column: location.range.end.column, + }, + }; + return (Some(location.path), Some(range)); + } + (Some(path.clone()), None) + } + _ => (None, None), + } +} + +fn app_text_range(range: &CoreTextRange) -> AppTextRange { + AppTextRange { + start: AppTextPosition { + line: range.start.line, + column: range.start.column, + }, + end: AppTextPosition { + line: range.end.line, + column: range.end.column, + }, + } +} + +fn project_config_warning(config: &Config) -> Option { + let mut disabled_folders = Vec::new(); + + for layer in config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + { + if !matches!(layer.name, ConfigLayerSource::Project { .. }) + || layer.disabled_reason.is_none() + { + continue; + } + if let ConfigLayerSource::Project { dot_codex_folder } = &layer.name { + disabled_folders.push(( + dot_codex_folder.as_path().display().to_string(), + layer + .disabled_reason + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "Config folder disabled.".to_string()), + )); + } + } + + if disabled_folders.is_empty() { + return None; + } + + let mut message = "The following config folders are disabled:\n".to_string(); + for (index, (folder, reason)) in disabled_folders.iter().enumerate() { + let display_index = index + 1; + message.push_str(&format!(" {display_index}. {folder}\n")); + message.push_str(&format!(" {reason}\n")); + } + + Some(ConfigWarningNotification { + summary: message, + details: None, + path: None, + range: None, + }) +} + pub async fn run_main( codex_linux_sandbox_exe: Option, cli_config_overrides: CliConfigOverrides, @@ -95,10 +208,7 @@ pub async fn run_main( { Ok(config) => config, Err(err) => { - let message = ConfigWarningNotification { - summary: "Invalid configuration; using defaults.".to_string(), - details: Some(err.to_string()), - }; + let message = config_warning_from_error("Invalid configuration; using defaults.", &err); config_warnings.push(message); Config::load_default_with_cli_overrides(cli_kv_overrides.clone()).map_err(|e| { std::io::Error::new( @@ -112,13 +222,20 @@ pub async fn run_main( if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.features, &config.config_layer_stack).await { + let (path, range) = exec_policy_warning_location(&err); let message = ConfigWarningNotification { summary: "Error parsing rules; custom rules not applied.".to_string(), details: Some(err.to_string()), + path, + range, }; config_warnings.push(message); } + if let Some(warning) = project_config_warning(&config) { + config_warnings.push(warning); + } + let feedback = CodexFeedback::new(); let otel = codex_core::otel_init::build_provider( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index cf720ef831..7b4a599b00 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -286,6 +286,8 @@ mod tests { let notification = ServerNotification::ConfigWarning(ConfigWarningNotification { summary: "Config error: using defaults".to_string(), details: Some("error loading config: bad config".to_string()), + path: None, + range: None, }); let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index d75a589d00..7d25810261 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -1,7 +1,7 @@ //! Validates that the collaboration mode list endpoint returns the expected default presets. //! //! The test drives the app server through the MCP harness and asserts that the list response -//! includes the plan, pair programming, and execute modes with their default model and reasoning +//! includes the plan, coding, pair programming, and execute modes with their default model and reasoning //! effort settings, which keeps the API contract visible in one place. #![allow(clippy::unwrap_used)] @@ -16,7 +16,8 @@ use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::models_manager::test_builtin_collaboration_mode_presets; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::config_types::ModeKind; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -44,8 +45,23 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> { let CollaborationModeListResponse { data: items } = to_response::(response)?; - let expected = vec![plan_preset(), pair_programming_preset(), execute_preset()]; - assert_eq!(expected, items); + let expected = [ + plan_preset(), + code_preset(), + pair_programming_preset(), + execute_preset(), + ]; + assert_eq!(expected.len(), items.len()); + for (expected_mask, actual_mask) in expected.iter().zip(items.iter()) { + assert_eq!(expected_mask.name, actual_mask.name); + assert_eq!(expected_mask.mode, actual_mask.mode); + assert_eq!(expected_mask.model, actual_mask.model); + assert_eq!(expected_mask.reasoning_effort, actual_mask.reasoning_effort); + assert_eq!( + expected_mask.developer_instructions, + actual_mask.developer_instructions + ); + } Ok(()) } @@ -53,11 +69,11 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> { /// /// If the defaults change in the app server, this helper should be updated alongside the /// contract, or the test will fail in ways that imply a regression in the API. -fn plan_preset() -> CollaborationMode { +fn plan_preset() -> CollaborationModeMask { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| matches!(p, CollaborationMode::Plan(_))) + .find(|p| p.mode == Some(ModeKind::Plan)) .unwrap() } @@ -65,11 +81,20 @@ fn plan_preset() -> CollaborationMode { /// /// The helper keeps the expected model and reasoning defaults co-located with the test /// so that mismatches point directly at the API contract being exercised. -fn pair_programming_preset() -> CollaborationMode { +fn pair_programming_preset() -> CollaborationModeMask { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| matches!(p, CollaborationMode::PairProgramming(_))) + .find(|p| p.mode == Some(ModeKind::PairProgramming)) + .unwrap() +} + +/// Builds the code preset that the list response is expected to return. +fn code_preset() -> CollaborationModeMask { + let presets = test_builtin_collaboration_mode_presets(); + presets + .into_iter() + .find(|p| p.mode == Some(ModeKind::Code)) .unwrap() } @@ -77,10 +102,10 @@ fn pair_programming_preset() -> CollaborationMode { /// /// The execute preset uses a different reasoning effort to capture the higher-effort /// execution contract the server currently exposes. -fn execute_preset() -> CollaborationMode { +fn execute_preset() -> CollaborationModeMask { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| matches!(p, CollaborationMode::Execute(_))) + .find(|p| p.mode == Some(ModeKind::Execute)) .unwrap() } diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index c66dd05bec..4ee76bdca2 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use tokio::time::timeout; @@ -54,11 +55,14 @@ async fn request_user_input_round_trip() -> Result<()> { }], model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), - collaboration_mode: Some(CollaborationMode::Plan(Settings { - model: "mock-model".to_string(), - reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: None, - })), + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }, + }), ..Default::default() }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 32abc237e3..c06f387fd2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -77,8 +77,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { assert_ne!(thread.id, conversation_id); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); - assert!(thread.path.is_absolute()); - assert_ne!(thread.path, original_path); + let thread_path = thread.path.clone().expect("thread path"); + assert!(thread_path.is_absolute()); + assert_ne!(thread_path, original_path); assert!(thread.cwd.is_absolute()); assert_eq!(thread.source, SessionSource::VsCode); diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 8704a25468..d8ca3aa696 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -64,7 +64,7 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> { assert_eq!(thread.id, conversation_id); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); - assert!(thread.path.is_absolute()); + assert!(thread.path.as_ref().expect("thread path").is_absolute()); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 183532ea7d..6129c0de2f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -117,7 +117,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { assert_eq!(thread.id, conversation_id); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); - assert!(thread.path.is_absolute()); + assert!(thread.path.as_ref().expect("thread path").is_absolute()); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -169,7 +169,7 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; - let thread_path = thread.path.clone(); + let thread_path = thread.path.clone().expect("thread path"); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: "not-a-valid-thread-id".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index bf04eaf252..d375a2db2f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -38,6 +38,7 @@ use codex_core::features::FEATURES; use codex_core::features::Feature; use codex_core::protocol_config_types::ReasoningSummary; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; @@ -363,11 +364,14 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let collaboration_mode = CollaborationMode::Custom(Settings { - model: "mock-model-collab".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "mock-model-collab".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }; let turn_req = mcp .send_turn_start_request(TurnStartParams { diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index 02f08724ff..02f8c61c31 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -15,13 +15,12 @@ pub(crate) fn subagent_header(source: &Option) -> Option return None; }; match sub { + codex_protocol::protocol::SubAgentSource::Review => Some("review".to_string()), + codex_protocol::protocol::SubAgentSource::Compact => Some("compact".to_string()), + codex_protocol::protocol::SubAgentSource::ThreadSpawn { .. } => { + Some("collab_spawn".to_string()) + } codex_protocol::protocol::SubAgentSource::Other(label) => Some(label.clone()), - other => Some( - serde_json::to_value(other) - .ok() - .and_then(|v| v.as_str().map(std::string::ToString::to_string)) - .unwrap_or_else(|| "other".to_string()), - ), } } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8ee2848a98..c151356e3d 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -64,6 +64,7 @@ reqwest = { workspace = true, features = ["json", "stream"] } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_path_to_error = { workspace = true } serde_yaml = { workspace = true } sha1 = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 13cdbc6740..68b9ba699c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -350,6 +350,7 @@ "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ "plan", + "code", "pair_programming", "execute", "custom" diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 6c5ebf9f02..611c0d164c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -39,12 +39,20 @@ impl AgentControl { &self, config: crate::config::Config, prompt: String, + session_source: Option, ) -> CodexResult { let state = self.upgrade()?; let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; // The same `AgentControl` is sent to spawn the thread. - let new_thread = state.spawn_new_thread(config, self.clone()).await?; + let new_thread = match session_source { + Some(session_source) => { + state + .spawn_new_thread_with_source(config, self.clone(), session_source) + .await? + } + None => state.spawn_new_thread(config, self.clone()).await?, + }; reservation.commit(new_thread.thread_id); // Notify a new thread has been created. This notification will be processed by clients @@ -268,7 +276,7 @@ mod tests { let control = AgentControl::default(); let (_home, config) = test_config().await; let err = control - .spawn_agent(config, "hello".to_string()) + .spawn_agent(config, "hello".to_string(), None) .await .expect_err("spawn_agent should fail without a manager"); assert_eq!( @@ -370,7 +378,7 @@ mod tests { let harness = AgentControlHarness::new().await; let thread_id = harness .control - .spawn_agent(harness.config.clone(), "spawned".to_string()) + .spawn_agent(harness.config.clone(), "spawned".to_string(), None) .await .expect("spawn_agent should succeed"); let _thread = harness @@ -417,12 +425,12 @@ mod tests { .expect("start thread"); let first_agent_id = control - .spawn_agent(config.clone(), "hello".to_string()) + .spawn_agent(config.clone(), "hello".to_string(), None) .await .expect("spawn_agent should succeed"); let err = control - .spawn_agent(config, "hello again".to_string()) + .spawn_agent(config, "hello again".to_string(), None) .await .expect_err("spawn_agent should respect max threads"); let CodexErr::AgentLimitReached { @@ -455,7 +463,7 @@ mod tests { let control = manager.agent_control(); let first_agent_id = control - .spawn_agent(config.clone(), "hello".to_string()) + .spawn_agent(config.clone(), "hello".to_string(), None) .await .expect("spawn_agent should succeed"); let _ = control @@ -464,7 +472,7 @@ mod tests { .expect("shutdown agent"); let second_agent_id = control - .spawn_agent(config.clone(), "hello again".to_string()) + .spawn_agent(config.clone(), "hello again".to_string(), None) .await .expect("spawn_agent should succeed after shutdown"); let _ = control @@ -490,12 +498,12 @@ mod tests { let cloned = control.clone(); let first_agent_id = cloned - .spawn_agent(config.clone(), "hello".to_string()) + .spawn_agent(config.clone(), "hello".to_string(), None) .await .expect("spawn_agent should succeed"); let err = control - .spawn_agent(config, "hello again".to_string()) + .spawn_agent(config, "hello again".to_string(), None) .await .expect_err("spawn_agent should respect shared guard"); let CodexErr::AgentLimitReached { max_threads } = err else { diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index c384ed7cd8..2f146f2f80 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -1,6 +1,8 @@ use crate::error::CodexErr; use crate::error::Result; use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use std::collections::HashSet; use std::sync::Arc; use std::sync::Mutex; @@ -19,6 +21,25 @@ pub(crate) struct Guards { total_count: AtomicUsize, } +/// Initial agent is depth 0. +pub(crate) const MAX_THREAD_SPAWN_DEPTH: i32 = 1; + +fn session_depth(session_source: &SessionSource) -> i32 { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, + SessionSource::SubAgent(_) => 0, + _ => 0, + } +} + +pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 { + session_depth(session_source).saturating_add(1) +} + +pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32) -> bool { + depth > MAX_THREAD_SPAWN_DEPTH +} + impl Guards { pub(crate) fn reserve_spawn_slot( self: &Arc, @@ -102,6 +123,30 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn session_depth_defaults_to_zero_for_root_sources() { + assert_eq!(session_depth(&SessionSource::Cli), 0); + } + + #[test] + fn thread_spawn_depth_increments_and_enforces_limit() { + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 1, + }); + let child_depth = next_thread_spawn_depth(&session_source); + assert_eq!(child_depth, 2); + assert!(exceeds_thread_spawn_depth_limit(child_depth)); + } + + #[test] + fn non_thread_spawn_subagents_default_to_depth_zero() { + let session_source = SessionSource::SubAgent(SubAgentSource::Review); + assert_eq!(session_depth(&session_source), 0); + assert_eq!(next_thread_spawn_depth(&session_source), 1); + assert!(!exceeds_thread_spawn_depth_limit(1)); + } + #[test] fn reservation_drop_releases_slot() { let guards = Arc::new(Guards::default()); diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 180f70dbe6..03652e43e5 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,10 +1,12 @@ pub(crate) mod control; -// Do not put in `pub` or `pub(crate)`. This code should not be used somewhere else. mod guards; pub(crate) mod role; pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; +pub(crate) use guards::MAX_THREAD_SPAWN_DEPTH; +pub(crate) use guards::exceeds_thread_spawn_depth_limit; +pub(crate) use guards::next_thread_spawn_depth; pub(crate) use role::AgentRole; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f0f2b125fd..437380deef 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -226,13 +226,11 @@ impl ModelClient { let mut extra_headers = ApiHeaderMap::new(); if let SessionSource::SubAgent(sub) = &self.state.session_source { - let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub { - label.clone() - } else { - serde_json::to_value(sub) - .ok() - .and_then(|v| v.as_str().map(std::string::ToString::to_string)) - .unwrap_or_else(|| "other".to_string()) + let subagent = match sub { + crate::protocol::SubAgentSource::Review => "review".to_string(), + crate::protocol::SubAgentSource::Compact => "compact".to_string(), + crate::protocol::SubAgentSource::ThreadSpawn { .. } => "collab_spawn".to_string(), + crate::protocol::SubAgentSource::Other(label) => label.clone(), }; if let Ok(val) = HeaderValue::from_str(&subagent) { extra_headers.insert("x-openai-subagent", val); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea52aad32f..9fc2bae165 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -35,6 +35,7 @@ use async_channel::Receiver; use async_channel::Sender; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::TurnItem; @@ -88,6 +89,7 @@ use crate::client::ModelClient; use crate::client::ModelClientSession; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; +use crate::codex_thread::ThreadConfigSnapshot; use crate::compact::collect_user_messages; use crate::config::Config; use crate::config::Constrained; @@ -191,6 +193,7 @@ pub struct Codex { pub(crate) rx_event: Receiver, // Last known status of the agent. pub(crate) agent_status: watch::Receiver, + pub(crate) session: Arc, } /// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], @@ -295,11 +298,14 @@ impl Codex { // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode // to avoid extracting these fields separately and constructing CollaborationMode here. - let collaboration_mode = CollaborationMode::Custom(Settings { - model: model.clone(), - reasoning_effort: config.model_reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: model.clone(), + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -341,12 +347,13 @@ impl Codex { let thread_id = session.conversation_id; // This task will run until Op::Shutdown is received. - tokio::spawn(submission_loop(session, config, rx_sub)); + tokio::spawn(submission_loop(Arc::clone(&session), config, rx_sub)); let codex = Codex { next_id: AtomicU64::new(0), tx_sub, rx_event, agent_status: agent_status_rx, + session, }; #[allow(deprecated)] @@ -390,6 +397,11 @@ impl Codex { pub(crate) async fn agent_status(&self) -> AgentStatus { self.agent_status.borrow().clone() } + + pub(crate) async fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { + let state = self.session.state.lock().await; + state.session_configuration.thread_config_snapshot() + } } /// Context for an initialized model agent @@ -491,6 +503,19 @@ pub(crate) struct SessionConfiguration { } impl SessionConfiguration { + fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { + ThreadConfigSnapshot { + model: self.collaboration_mode.model().to_string(), + model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), + approval_policy: self.approval_policy.value(), + sandbox_policy: self.sandbox_policy.get().clone(), + cwd: self.cwd.clone(), + reasoning_effort: self.collaboration_mode.reasoning_effort(), + personality: self.personality, + session_source: self.session_source.clone(), + } + } + pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult { let mut next_configuration = self.clone(); if let Some(collaboration_mode) = updates.collaboration_mode.clone() { @@ -648,7 +673,15 @@ impl Session { // - initialize RolloutRecorder with new or resumed session info // - perform default shell discovery // - load history metadata - let rollout_fut = RolloutRecorder::new(&config, rollout_params); + let rollout_fut = async { + if config.ephemeral { + Ok(None) + } else { + RolloutRecorder::new(&config, rollout_params) + .await + .map(Some) + } + }; let history_meta_fut = crate::message_history::history_metadata(&config); let auth_manager_clone = Arc::clone(&auth_manager); @@ -675,7 +708,9 @@ impl Session { error!("failed to initialize rollout recorder: {e:#}"); anyhow::Error::from(e) })?; - let rollout_path = rollout_recorder.rollout_path.clone(); + let rollout_path = rollout_recorder + .as_ref() + .map(|rec| rec.rollout_path.clone()); let mut post_session_configured_events = Vec::::new(); @@ -764,7 +799,7 @@ impl Session { mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::default(), notifier: UserNotifier::new(config.notify.clone()), - rollout: Mutex::new(Some(rollout_recorder)), + rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), show_raw_agent_reasoning: config.show_raw_agent_reasoning, exec_policy, @@ -2215,6 +2250,7 @@ mod handlers { use crate::context_manager::is_user_turn_boundary; use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; @@ -2291,11 +2327,14 @@ mod handlers { personality, } => { let collaboration_mode = collaboration_mode.or_else(|| { - Some(CollaborationMode::Custom(Settings { - model: model.clone(), - reasoning_effort: effort, - developer_instructions: None, - })) + Some(CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: model.clone(), + reasoning_effort: effort, + developer_instructions: None, + }, + }) }); ( items, @@ -3878,11 +3917,14 @@ mod tests { let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode::Custom(Settings { - model, - reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -3954,11 +3996,14 @@ mod tests { let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode::Custom(Settings { - model, - reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -4214,11 +4259,14 @@ mod tests { let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode::Custom(Settings { - model, - reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -4319,11 +4367,14 @@ mod tests { let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode::Custom(Settings { - model, - reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index b855612113..9a94f988d5 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -92,6 +92,7 @@ pub(crate) async fn run_codex_thread_interactive( tx_sub: tx_ops, rx_event: rx_sub, agent_status: codex.agent_status.clone(), + session: Arc::clone(&codex.session), }) } @@ -134,6 +135,7 @@ pub(crate) async fn run_codex_thread_one_shot( let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let ops_tx = io.tx_sub.clone(); let agent_status = io.agent_status.clone(); + let session = Arc::clone(&io.session); let io_for_bridge = io; tokio::spawn(async move { while let Ok(event) = io_for_bridge.next_event().await { @@ -166,6 +168,7 @@ pub(crate) async fn run_codex_thread_one_shot( rx_event: rx_bridge, tx_sub: tx_closed, agent_status, + session, }) } @@ -442,15 +445,15 @@ mod tests { let (tx_events, rx_events) = bounded(1); let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await; let codex = Arc::new(Codex { next_id: AtomicU64::new(0), tx_sub, rx_event: rx_events, agent_status, + session: Arc::clone(&session), }); - let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await; - let (tx_out, rx_out) = bounded(1); tx_out .send(Event { diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 6d132a9256..152679d146 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -4,18 +4,35 @@ use crate::error::Result as CodexResult; use crate::protocol::Event; use crate::protocol::Op; use crate::protocol::Submission; +use codex_protocol::config_types::Personality; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use std::path::PathBuf; use tokio::sync::watch; +#[derive(Clone, Debug)] +pub struct ThreadConfigSnapshot { + pub model: String, + pub model_provider_id: String, + pub approval_policy: AskForApproval, + pub sandbox_policy: SandboxPolicy, + pub cwd: PathBuf, + pub reasoning_effort: Option, + pub personality: Option, + pub session_source: SessionSource, +} + pub struct CodexThread { codex: Codex, - rollout_path: PathBuf, + rollout_path: Option, } /// Conduit for the bidirectional stream of messages that compose a thread /// (formerly called a conversation) in Codex. impl CodexThread { - pub(crate) fn new(codex: Codex, rollout_path: PathBuf) -> Self { + pub(crate) fn new(codex: Codex, rollout_path: Option) -> Self { Self { codex, rollout_path, @@ -43,7 +60,11 @@ impl CodexThread { self.codex.agent_status.clone() } - pub fn rollout_path(&self) -> PathBuf { + pub fn rollout_path(&self) -> Option { self.rollout_path.clone() } + + pub async fn config_snapshot(&self) -> ThreadConfigSnapshot { + self.codex.thread_config_snapshot().await + } } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ada42663bf..ca3ccc1ffd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,7 +89,7 @@ pub use codex_git::GhostSnapshotConfig; /// files are *silently truncated* to this size so we do not take up too much of /// the context window. pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB -pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = None; +pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(6); pub const CONFIG_TOML_FILE: &str = "config.toml"; @@ -261,6 +261,9 @@ pub struct Config { /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. pub history: History, + /// When true, session is not persisted on disk. Default to `false` + pub ephemeral: bool, + /// Optional URI-based file opener. If set, citations to files in the model /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, @@ -410,9 +413,21 @@ impl ConfigBuilder { // relative paths to absolute paths based on the parent folder of the // respective config file, so we should be safe to deserialize without // AbsolutePathBufGuard here. - let config_toml: ConfigToml = merged_toml - .try_into() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let config_toml: ConfigToml = match merged_toml.try_into() { + Ok(config_toml) => config_toml, + Err(err) => { + if let Some(config_error) = + crate::config_loader::first_layer_config_error(&config_layer_stack).await + { + return Err(crate::config_loader::io_error_from_config_error( + std::io::ErrorKind::InvalidData, + config_error, + Some(err), + )); + } + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err)); + } + }; Config::load_config_with_layer_stack( config_toml, harness_overrides, @@ -1146,6 +1161,7 @@ pub struct ConfigOverrides { pub include_apply_patch_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, + pub ephemeral: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -1234,6 +1250,7 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, + ephemeral, additional_writable_roots, } = overrides; @@ -1518,6 +1535,7 @@ impl Config { codex_home, config_layer_stack, history, + ephemeral: ephemeral.unwrap_or_default(), file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_linux_sandbox_exe, @@ -3675,10 +3693,11 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, - agent_max_threads: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), + ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, hide_agent_reasoning: false, @@ -3756,10 +3775,11 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, - agent_max_threads: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), + ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, hide_agent_reasoning: false, @@ -3852,10 +3872,11 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, - agent_max_threads: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), + ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, hide_agent_reasoning: false, @@ -3934,10 +3955,11 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, - agent_max_threads: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), + ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, hide_agent_reasoning: false, diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index d1a5759d8b..961c40e02b 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -4,6 +4,7 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::ConfigRequirementsToml; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; @@ -171,7 +172,7 @@ impl ConfigService { origins: layers.origins(), layers: params.include_layers.then(|| { layers - .layers_high_to_low() + .get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst, true) .iter() .map(|layer| layer.as_layer()) .collect() diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md index d0df9a7349..67170644f5 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/core/src/config_loader/README.md @@ -16,7 +16,7 @@ Exported from `codex_core::config_loader`: - `origins() -> HashMap` - `layers_high_to_low() -> Vec` - `with_user_config(user_config) -> ConfigLayerStack` -- `ConfigLayerEntry` (one layer’s `{name, config, version}`; `name` carries source metadata) +- `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata) - `LoaderOverrides` (test/override hooks for managed config sources) - `merge_toml_values(base, overlay)` (public helper used elsewhere) @@ -29,7 +29,9 @@ Precedence is **top overrides bottom**: 3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) 4. **User** config (`config.toml`) -This is what `ConfigLayerStack::effective_config()` implements. +Layers with a `disabled_reason` are still surfaced for UI, but are ignored when +computing the effective config and origins metadata. This is what +`ConfigLayerStack::effective_config()` implements. ## Typical usage diff --git a/codex-rs/core/src/config_loader/diagnostics.rs b/codex-rs/core/src/config_loader/diagnostics.rs new file mode 100644 index 0000000000..64f9c83888 --- /dev/null +++ b/codex-rs/core/src/config_loader/diagnostics.rs @@ -0,0 +1,388 @@ +//! Helpers for mapping config parse/validation failures to file locations and +//! rendering them in a user-friendly way. + +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigToml; +use codex_app_server_protocol::ConfigLayerSource; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde_path_to_error::Path as SerdePath; +use serde_path_to_error::Segment as SerdeSegment; +use std::fmt; +use std::fmt::Write; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use toml_edit::Document; +use toml_edit::Item; +use toml_edit::Table; +use toml_edit::Value; + +use super::ConfigLayerEntry; +use super::ConfigLayerStack; +use super::ConfigLayerStackOrdering; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextPosition { + pub line: usize, + pub column: usize, +} + +/// Text range in 1-based line/column coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigError { + pub path: PathBuf, + pub range: TextRange, + pub message: String, +} + +impl ConfigError { + pub fn new(path: PathBuf, range: TextRange, message: impl Into) -> Self { + Self { + path, + range, + message: message.into(), + } + } +} + +#[derive(Debug)] +pub struct ConfigLoadError { + error: ConfigError, + source: Option, +} + +impl ConfigLoadError { + pub fn new(error: ConfigError, source: Option) -> Self { + Self { error, source } + } + + pub fn config_error(&self) -> &ConfigError { + &self.error + } +} + +impl fmt::Display for ConfigLoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}:{}: {}", + self.error.path.display(), + self.error.range.start.line, + self.error.range.start.column, + self.error.message + ) + } +} + +impl std::error::Error for ConfigLoadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|err| err as &dyn std::error::Error) + } +} + +pub(crate) fn io_error_from_config_error( + kind: io::ErrorKind, + error: ConfigError, + source: Option, +) -> io::Error { + io::Error::new(kind, ConfigLoadError::new(error, source)) +} + +pub(crate) fn config_error_from_toml( + path: impl AsRef, + contents: &str, + err: toml::de::Error, +) -> ConfigError { + let range = err + .span() + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + ConfigError::new(path.as_ref().to_path_buf(), range, err.message()) +} + +pub(crate) fn config_error_from_config_toml( + path: impl AsRef, + contents: &str, +) -> Option { + let deserializer = match toml::de::Deserializer::parse(contents) { + Ok(deserializer) => deserializer, + Err(err) => return Some(config_error_from_toml(path, contents, err)), + }; + + let result: Result = serde_path_to_error::deserialize(deserializer); + match result { + Ok(_) => None, + Err(err) => { + let path_hint = err.path().clone(); + let toml_err: toml::de::Error = err.into_inner(); + let range = span_for_config_path(contents, &path_hint) + .or_else(|| toml_err.span()) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + path.as_ref().to_path_buf(), + range, + toml_err.message(), + )) + } + } +} + +pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { + // When the merged config fails schema validation, we surface the first concrete + // per-file error to point users at a specific file and range rather than an + // opaque merged-layer failure. + first_layer_config_error_for_entries( + layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false), + ) + .await +} + +pub(crate) async fn first_layer_config_error_from_entries( + layers: &[ConfigLayerEntry], +) -> Option { + first_layer_config_error_for_entries(layers.iter()).await +} + +async fn first_layer_config_error_for_entries<'a, I>(layers: I) -> Option +where + I: IntoIterator, +{ + for layer in layers { + let Some(path) = config_path_for_layer(layer) else { + continue; + }; + let contents = match tokio::fs::read_to_string(&path).await { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + tracing::debug!("Failed to read config file {}: {err}", path.display()); + continue; + } + }; + + let Some(parent) = path.parent() else { + tracing::debug!("Config file {} has no parent directory", path.display()); + continue; + }; + let _guard = AbsolutePathBufGuard::new(parent); + if let Some(error) = config_error_from_config_toml(&path, &contents) { + return Some(error); + } + } + + None +} + +fn config_path_for_layer(layer: &ConfigLayerEntry) -> Option { + match &layer.name { + ConfigLayerSource::System { file } => Some(file.to_path_buf()), + ConfigLayerSource::User { file } => Some(file.to_path_buf()), + ConfigLayerSource::Project { dot_codex_folder } => { + Some(dot_codex_folder.as_path().join(CONFIG_TOML_FILE)) + } + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()), + ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::SessionFlags + | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, + } +} + +fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { + let start = position_for_offset(contents, span.start); + let end_index = if span.end > span.start { + span.end - 1 + } else { + span.end + }; + let end = position_for_offset(contents, end_index); + TextRange { start, end } +} + +pub fn format_config_error(error: &ConfigError, contents: &str) -> String { + let mut output = String::new(); + let start = error.range.start; + let _ = writeln!( + output, + "{}:{}:{}: {}", + error.path.display(), + start.line, + start.column, + error.message + ); + + let line_index = start.line.saturating_sub(1); + let line = match contents.lines().nth(line_index) { + Some(line) => line.trim_end_matches('\r'), + None => return output.trim_end().to_string(), + }; + + let line_number = start.line; + let gutter = line_number.to_string().len(); + let _ = writeln!(output, "{:width$} |", "", width = gutter); + let _ = writeln!(output, "{line_number:>gutter$} | {line}"); + + let highlight_len = if error.range.end.line == error.range.start.line + && error.range.end.column >= error.range.start.column + { + error.range.end.column - error.range.start.column + 1 + } else { + 1 + }; + let spaces = " ".repeat(start.column.saturating_sub(1)); + let carets = "^".repeat(highlight_len.max(1)); + let _ = writeln!(output, "{:width$} | {spaces}{carets}", "", width = gutter); + output.trim_end().to_string() +} + +pub fn format_config_error_with_source(error: &ConfigError) -> String { + match std::fs::read_to_string(&error.path) { + Ok(contents) => format_config_error(error, &contents), + Err(_) => format_config_error(error, ""), + } +} + +fn position_for_offset(contents: &str, index: usize) -> TextPosition { + let bytes = contents.as_bytes(); + if bytes.is_empty() { + return TextPosition { line: 1, column: 1 }; + } + + let safe_index = index.min(bytes.len().saturating_sub(1)); + let column_offset = index.saturating_sub(safe_index); + let index = safe_index; + + let line_start = bytes[..index] + .iter() + .rposition(|byte| *byte == b'\n') + .map(|pos| pos + 1) + .unwrap_or(0); + let line = bytes[..line_start] + .iter() + .filter(|byte| **byte == b'\n') + .count(); + + let column = std::str::from_utf8(&bytes[line_start..=index]) + .map(|slice| slice.chars().count().saturating_sub(1)) + .unwrap_or_else(|_| index - line_start); + let column = column + column_offset; + + TextPosition { + line: line + 1, + column: column + 1, + } +} + +fn default_range() -> TextRange { + let position = TextPosition { line: 1, column: 1 }; + TextRange { + start: position, + end: position, + } +} + +enum TomlNode<'a> { + Item(&'a Item), + Table(&'a Table), + Value(&'a Value), +} + +fn span_for_path(contents: &str, path: &SerdePath) -> Option> { + let doc = contents.parse::>().ok()?; + let node = node_for_path(doc.as_item(), path)?; + match node { + TomlNode::Item(item) => item.span(), + TomlNode::Table(table) => table.span(), + TomlNode::Value(value) => value.span(), + } +} + +fn span_for_config_path(contents: &str, path: &SerdePath) -> Option> { + if is_features_table_path(path) + && let Some(span) = span_for_features_value(contents) + { + return Some(span); + } + span_for_path(contents, path) +} + +fn is_features_table_path(path: &SerdePath) -> bool { + let mut segments = path.iter(); + matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features") + && segments.next().is_none() +} + +fn span_for_features_value(contents: &str) -> Option> { + let doc = contents.parse::>().ok()?; + let root = doc.as_item().as_table_like()?; + let features_item = root.get("features")?; + let features_table = features_item.as_table_like()?; + for (_, item) in features_table.iter() { + match item { + Item::Value(Value::Boolean(_)) => continue, + Item::Value(value) => return value.span(), + Item::Table(table) => return table.span(), + Item::ArrayOfTables(array) => return array.span(), + Item::None => continue, + } + } + None +} + +fn node_for_path<'a>(item: &'a Item, path: &SerdePath) -> Option> { + let segments: Vec<_> = path.iter().cloned().collect(); + let mut node = TomlNode::Item(item); + let mut index = 0; + while index < segments.len() { + match &segments[index] { + SerdeSegment::Map { key } | SerdeSegment::Enum { variant: key } => { + if let Some(next) = map_child(&node, key) { + node = next; + index += 1; + continue; + } + + if index + 1 < segments.len() { + index += 1; + continue; + } + return None; + } + SerdeSegment::Seq { index: seq_index } => { + node = seq_child(&node, *seq_index)?; + index += 1; + } + SerdeSegment::Unknown => return None, + } + } + Some(node) +} + +fn map_child<'a>(node: &TomlNode<'a>, key: &str) -> Option> { + match node { + TomlNode::Item(item) => { + let table = item.as_table_like()?; + table.get(key).map(TomlNode::Item) + } + TomlNode::Table(table) => table.get(key).map(TomlNode::Item), + TomlNode::Value(Value::InlineTable(table)) => table.get(key).map(TomlNode::Value), + _ => None, + } +} + +fn seq_child<'a>(node: &TomlNode<'a>, index: usize) -> Option> { + match node { + TomlNode::Item(Item::Value(Value::Array(array))) => array.get(index).map(TomlNode::Value), + TomlNode::Item(Item::ArrayOfTables(array)) => array.get(index).map(TomlNode::Table), + TomlNode::Value(Value::Array(array)) => array.get(index).map(TomlNode::Value), + _ => None, + } +} diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs index 0ece69b471..f6f7d2e37d 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/core/src/config_loader/layer_io.rs @@ -1,4 +1,6 @@ use super::LoaderOverrides; +use super::diagnostics::config_error_from_toml; +use super::diagnostics::io_error_from_config_error; #[cfg(target_os = "macos")] use super::macos::load_managed_admin_config_layer; use codex_utils_absolute_path::AbsolutePathBuf; @@ -75,7 +77,12 @@ pub(super) async fn read_config_from_path( Ok(value) => Ok(Some(value)), Err(err) => { tracing::error!("Failed to parse {}: {err}", path.as_ref().display()); - Err(io::Error::new(io::ErrorKind::InvalidData, err)) + let config_error = config_error_from_toml(path.as_ref(), &contents, err.clone()); + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + Some(err), + )) } }, Err(err) if err.kind() == io::ErrorKind::NotFound => { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index ffde19f1ff..6d85a79360 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -1,4 +1,5 @@ mod config_requirements; +mod diagnostics; mod fingerprint; mod layer_io; #[cfg(target_os = "macos")] @@ -34,6 +35,16 @@ pub use config_requirements::McpServerRequirement; pub use config_requirements::RequirementSource; pub use config_requirements::SandboxModeRequirement; pub use config_requirements::Sourced; +pub use diagnostics::ConfigError; +pub use diagnostics::ConfigLoadError; +pub use diagnostics::TextPosition; +pub use diagnostics::TextRange; +pub(crate) use diagnostics::config_error_from_toml; +pub(crate) use diagnostics::first_layer_config_error; +pub(crate) use diagnostics::first_layer_config_error_from_entries; +pub use diagnostics::format_config_error; +pub use diagnostics::format_config_error_with_source; +pub(crate) use diagnostics::io_error_from_config_error; pub use merge::merge_toml_values; pub(crate) use overrides::build_cli_overrides_layer; pub use state::ConfigLayerEntry; @@ -67,9 +78,9 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` /// - user `${CODEX_HOME}/config.toml` -/// - cwd `${PWD}/config.toml` (only when the directory is trusted) -/// - tree parent directories up to root looking for `./.codex/config.toml` (trusted only) -/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (trusted only) +/// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) +/// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted) +/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted) /// - runtime e.g., --config flags, model selector in UI /// /// (*) Only available on macOS via managed device profiles. @@ -171,14 +182,51 @@ pub async fn load_config_layers_state( merge_toml_values(&mut merged_so_far, cli_overrides_layer); } - let project_root_markers = project_root_markers_from_config(&merged_so_far)? - .unwrap_or_else(default_project_root_markers); - if let Some(project_root) = - trusted_project_root(&merged_so_far, &cwd, &project_root_markers, codex_home).await? + let project_root_markers = match project_root_markers_from_config(&merged_so_far) { + Ok(markers) => markers.unwrap_or_else(default_project_root_markers), + Err(err) => { + if let Some(config_error) = first_layer_config_error_from_entries(&layers).await { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + None, + )); + } + return Err(err); + } + }; + let project_trust_context = match project_trust_context( + &merged_so_far, + &cwd, + &project_root_markers, + codex_home, + &user_file, + ) + .await { - let project_layers = load_project_layers(&cwd, &project_root).await?; - layers.extend(project_layers); - } + Ok(context) => context, + Err(err) => { + let source = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .cloned(); + if let Some(config_error) = first_layer_config_error_from_entries(&layers).await { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + source, + )); + } + return Err(err); + } + }; + let project_layers = load_project_layers( + &cwd, + &project_trust_context.project_root, + &project_trust_context, + ) + .await?; + layers.extend(project_layers); } // Add a layer for runtime overrides from the CLI or UI, if any exist. @@ -243,11 +291,9 @@ async fn load_config_toml_for_required_layer( let toml_file = config_toml.as_ref(); let toml_value = match tokio::fs::read_to_string(toml_file).await { Ok(contents) => { - let config: TomlValue = toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("Error parsing config file {}: {e}", toml_file.display()), - ) + let config: TomlValue = toml::from_str(&contents).map_err(|err| { + let config_error = config_error_from_toml(toml_file, &contents, err.clone()); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) })?; let config_parent = toml_file.parent().ok_or_else(|| { io::Error::new( @@ -402,42 +448,132 @@ fn default_project_root_markers() -> Vec { .collect() } -async fn trusted_project_root( +struct ProjectTrustContext { + project_root: AbsolutePathBuf, + project_root_key: String, + repo_root_key: Option, + projects_trust: std::collections::HashMap, + user_config_file: AbsolutePathBuf, +} + +struct ProjectTrustDecision { + trust_level: Option, + trust_key: String, +} + +impl ProjectTrustDecision { + fn is_trusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Trusted)) + } +} + +impl ProjectTrustContext { + fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision { + let dir_key = dir.as_path().to_string_lossy().to_string(); + if let Some(trust_level) = self.projects_trust.get(&dir_key).copied() { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key: dir_key, + }; + } + + if let Some(trust_level) = self.projects_trust.get(&self.project_root_key).copied() { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key: self.project_root_key.clone(), + }; + } + + if let Some(repo_root_key) = self.repo_root_key.as_ref() + && let Some(trust_level) = self.projects_trust.get(repo_root_key).copied() + { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key: repo_root_key.clone(), + }; + } + + ProjectTrustDecision { + trust_level: None, + trust_key: self + .repo_root_key + .clone() + .unwrap_or_else(|| self.project_root_key.clone()), + } + } + + fn disabled_reason_for_dir(&self, dir: &AbsolutePathBuf) -> Option { + let decision = self.decision_for_dir(dir); + if decision.is_trusted() { + return None; + } + + let trust_key = decision.trust_key.as_str(); + let user_config_file = self.user_config_file.as_path().display(); + match decision.trust_level { + Some(TrustLevel::Untrusted) => Some(format!( + "{trust_key} is marked as untrusted in {user_config_file}. Mark it trusted to enable project config folders." + )), + _ => Some(format!( + "Add {trust_key} as a trusted project in {user_config_file}." + )), + } + } +} + +fn project_layer_entry( + trust_context: &ProjectTrustContext, + dot_codex_folder: &AbsolutePathBuf, + layer_dir: &AbsolutePathBuf, + config: TomlValue, +) -> ConfigLayerEntry { + match trust_context.disabled_reason_for_dir(layer_dir) { + Some(reason) => ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: dot_codex_folder.clone(), + }, + config, + reason, + ), + None => ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: dot_codex_folder.clone(), + }, + config, + ), + } +} + +async fn project_trust_context( merged_config: &TomlValue, cwd: &AbsolutePathBuf, project_root_markers: &[String], config_base_dir: &Path, -) -> io::Result> { + user_config_file: &AbsolutePathBuf, +) -> io::Result { let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?; let project_root = find_project_root(cwd, project_root_markers).await?; let projects = config_toml.projects.unwrap_or_default(); - let cwd_key = cwd.as_path().to_string_lossy().to_string(); let project_root_key = project_root.as_path().to_string_lossy().to_string(); - let repo_root_key = resolve_root_git_project_for_trust(cwd.as_path()) + let repo_root = resolve_root_git_project_for_trust(cwd.as_path()); + let repo_root_key = repo_root + .as_ref() .map(|root| root.to_string_lossy().to_string()); - let trust_level = projects - .get(&cwd_key) - .and_then(|project| project.trust_level) - .or_else(|| { - projects - .get(&project_root_key) - .and_then(|project| project.trust_level) - }) - .or_else(|| { - repo_root_key - .as_ref() - .and_then(|root| projects.get(root)) - .and_then(|project| project.trust_level) - }); + let projects_trust = projects + .into_iter() + .filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level))) + .collect(); - if matches!(trust_level, Some(TrustLevel::Trusted)) { - Ok(Some(project_root)) - } else { - Ok(None) - } + Ok(ProjectTrustContext { + project_root, + project_root_key, + repo_root_key, + projects_trust, + user_config_file: user_config_file.clone(), + }) } /// Takes a `toml::Value` parsed from a config.toml file and walks through it, @@ -527,6 +663,7 @@ async fn find_project_root( async fn load_project_layers( cwd: &AbsolutePathBuf, project_root: &AbsolutePathBuf, + trust_context: &ProjectTrustContext, ) -> io::Result> { let mut dirs = cwd .as_path() @@ -555,46 +692,54 @@ async fn load_project_layers( continue; } + let layer_dir = AbsolutePathBuf::from_absolute_path(dir)?; + let decision = trust_context.decision_for_dir(&layer_dir); let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?; let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?; match tokio::fs::read_to_string(&config_file).await { Ok(contents) => { - let config: TomlValue = toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing project config file {}: {e}", - config_file.as_path().display(), - ), - ) - })?; + let config: TomlValue = match toml::from_str(&contents) { + Ok(config) => config, + Err(e) => { + if decision.is_trusted() { + let config_file_display = config_file.as_path().display(); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing project config file {config_file_display}: {e}" + ), + )); + } + layers.push(project_layer_entry( + trust_context, + &dot_codex_abs, + &layer_dir, + TomlValue::Table(toml::map::Map::new()), + )); + continue; + } + }; let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; - layers.push(ConfigLayerEntry::new( - ConfigLayerSource::Project { - dot_codex_folder: dot_codex_abs, - }, - config, - )); + let entry = project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config); + layers.push(entry); } Err(err) => { if err.kind() == io::ErrorKind::NotFound { // If there is no config.toml file, record an empty entry // for this project layer, as this may still have subfolders // that are significant in the overall ConfigLayerStack. - layers.push(ConfigLayerEntry::new( - ConfigLayerSource::Project { - dot_codex_folder: dot_codex_abs, - }, + layers.push(project_layer_entry( + trust_context, + &dot_codex_abs, + &layer_dir, TomlValue::Table(toml::map::Map::new()), )); } else { + let config_file_display = config_file.as_path().display(); return Err(io::Error::new( err.kind(), - format!( - "Failed to read project config file {}: {err}", - config_file.as_path().display(), - ), + format!("Failed to read project config file {config_file_display}: {err}"), )); } } diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index 2b01a22644..847b19d7e5 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -28,6 +28,7 @@ pub struct ConfigLayerEntry { pub name: ConfigLayerSource, pub config: TomlValue, pub version: String, + pub disabled_reason: Option, } impl ConfigLayerEntry { @@ -37,9 +38,28 @@ impl ConfigLayerEntry { name, config, version, + disabled_reason: None, } } + pub fn new_disabled( + name: ConfigLayerSource, + config: TomlValue, + disabled_reason: impl Into, + ) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + version, + disabled_reason: Some(disabled_reason.into()), + } + } + + pub fn is_disabled(&self) -> bool { + self.disabled_reason.is_some() + } + pub fn metadata(&self) -> ConfigLayerMetadata { ConfigLayerMetadata { name: self.name.clone(), @@ -52,6 +72,7 @@ impl ConfigLayerEntry { name: self.name.clone(), version: self.version.clone(), config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null), + disabled_reason: self.disabled_reason.clone(), } } @@ -172,7 +193,7 @@ impl ConfigLayerStack { pub fn effective_config(&self) -> TomlValue { let mut merged = TomlValue::Table(toml::map::Map::new()); - for layer in &self.layers { + for layer in self.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) { merge_toml_values(&mut merged, &layer.config); } merged @@ -182,7 +203,7 @@ impl ConfigLayerStack { let mut origins = HashMap::new(); let mut path = Vec::new(); - for layer in &self.layers { + for layer in self.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) { record_origins(&layer.config, &layer.metadata(), &mut path, &mut origins); } @@ -192,16 +213,25 @@ impl ConfigLayerStack { /// Returns the highest-precedence to lowest-precedence layers, so /// `ConfigLayerSource::SessionFlags` would be first, if present. pub fn layers_high_to_low(&self) -> Vec<&ConfigLayerEntry> { - self.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst) + self.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst, false) } /// Returns the highest-precedence to lowest-precedence layers, so /// `ConfigLayerSource::SessionFlags` would be first, if present. - pub fn get_layers(&self, ordering: ConfigLayerStackOrdering) -> Vec<&ConfigLayerEntry> { - match ordering { - ConfigLayerStackOrdering::HighestPrecedenceFirst => self.layers.iter().rev().collect(), - ConfigLayerStackOrdering::LowestPrecedenceFirst => self.layers.iter().collect(), + pub fn get_layers( + &self, + ordering: ConfigLayerStackOrdering, + include_disabled: bool, + ) -> Vec<&ConfigLayerEntry> { + let mut layers: Vec<&ConfigLayerEntry> = self + .layers + .iter() + .filter(|layer| include_disabled || !layer.is_disabled()) + .collect(); + if ordering == ConfigLayerStackOrdering::HighestPrecedenceFirst { + layers.reverse(); } + layers } } diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 51132e54fe..03b0706c84 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -6,6 +6,7 @@ use crate::config::ConfigOverrides; use crate::config::ConfigToml; use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLoadError; use crate::config_loader::ConfigRequirements; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::fingerprint::version_for_toml; @@ -21,6 +22,13 @@ use std::path::Path; use tempfile::tempdir; use toml::Value as TomlValue; +fn config_error_from_io(err: &std::io::Error) -> &super::ConfigError { + err.get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error) + .expect("expected ConfigLoadError") +} + async fn make_config_for_test( codex_home: &Path, project_path: &Path, @@ -44,6 +52,98 @@ async fn make_config_for_test( .await } +#[tokio::test] +async fn returns_config_error_for_invalid_user_config_toml() { + let tmp = tempdir().expect("tempdir"); + let contents = "model = \"gpt-4\"\ninvalid = ["; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let err = load_config_layers_state( + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let expected_toml_error = toml::from_str::(contents).expect_err("parse error"); + let expected_config_error = + super::config_error_from_toml(&config_path, contents, expected_toml_error); + assert_eq!(config_error, &expected_config_error); +} + +#[tokio::test] +async fn returns_config_error_for_invalid_managed_config_toml() { + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + let contents = "model = \"gpt-4\"\ninvalid = ["; + std::fs::write(&managed_path, contents).expect("write managed config"); + + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path.clone()), + ..Default::default() + }; + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let err = load_config_layers_state( + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + overrides, + ) + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let expected_toml_error = toml::from_str::(contents).expect_err("parse error"); + let expected_config_error = + super::config_error_from_toml(&managed_path, contents, expected_toml_error); + assert_eq!(config_error, &expected_config_error); +} + +#[tokio::test] +async fn returns_config_error_for_schema_error_in_user_config() { + let tmp = tempdir().expect("tempdir"); + let contents = "model_context_window = \"not_a_number\""; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .build() + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let _guard = codex_utils_absolute_path::AbsolutePathBufGuard::new(tmp.path()); + let expected_config_error = + super::diagnostics::config_error_from_config_toml(&config_path, contents) + .expect("schema error"); + assert_eq!(config_error, &expected_config_error); +} + +#[test] +fn schema_error_points_to_feature_value() { + let tmp = tempdir().expect("tempdir"); + let contents = "[features]\ncollaboration_modes = \"true\""; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let _guard = codex_utils_absolute_path::AbsolutePathBufGuard::new(tmp.path()); + let error = super::diagnostics::config_error_from_config_toml(&config_path, contents) + .expect("schema error"); + + let value_line = contents.lines().nth(1).expect("value line"); + let value_column = value_line.find("\"true\"").expect("value") + 1; + assert_eq!(error.range.start.line, 2); + assert_eq!(error.range.start.column, value_column); +} + #[tokio::test] async fn merges_managed_config_layer_on_top() { let tmp = tempdir().expect("tempdir"); @@ -132,6 +232,7 @@ async fn returns_empty_when_all_layers_missing() { }, config: TomlValue::Table(toml::map::Map::new()), version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), + disabled_reason: None, }, user_layer, ); @@ -546,6 +647,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s }, config: TomlValue::Table(toml::map::Map::new()), version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), + disabled_reason: None, }], project_layers ); @@ -554,7 +656,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s } #[tokio::test] -async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<()> { +async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<()> { let tmp = tempdir()?; let project_root = tmp.path().join("project"); let nested = project_root.join("child"); @@ -576,6 +678,13 @@ async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<( None, ) .await?; + let untrusted_config_path = codex_home_untrusted.join(CONFIG_TOML_FILE); + let untrusted_config_contents = tokio::fs::read_to_string(&untrusted_config_path).await?; + tokio::fs::write( + &untrusted_config_path, + format!("foo = \"user\"\n{untrusted_config_contents}"), + ) + .await?; let layers_untrusted = load_config_layers_state( &codex_home_untrusted, @@ -584,16 +693,35 @@ async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<( LoaderOverrides::default(), ) .await?; - let project_layers_untrusted = layers_untrusted - .layers_high_to_low() + let project_layers_untrusted: Vec<_> = layers_untrusted + .get_layers( + super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + true, + ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) - .count(); - assert_eq!(project_layers_untrusted, 0); - assert_eq!(layers_untrusted.effective_config().get("foo"), None); + .collect(); + assert_eq!(project_layers_untrusted.len(), 1); + assert!( + project_layers_untrusted[0].disabled_reason.is_some(), + "expected untrusted project layer to be disabled" + ); + assert_eq!( + project_layers_untrusted[0].config.get("foo"), + Some(&TomlValue::String("child".to_string())) + ); + assert_eq!( + layers_untrusted.effective_config().get("foo"), + Some(&TomlValue::String("user".to_string())) + ); let codex_home_unknown = tmp.path().join("home_unknown"); tokio::fs::create_dir_all(&codex_home_unknown).await?; + tokio::fs::write( + codex_home_unknown.join(CONFIG_TOML_FILE), + "foo = \"user\"\n", + ) + .await?; let layers_unknown = load_config_layers_state( &codex_home_unknown, @@ -602,13 +730,92 @@ async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<( LoaderOverrides::default(), ) .await?; - let project_layers_unknown = layers_unknown - .layers_high_to_low() + let project_layers_unknown: Vec<_> = layers_unknown + .get_layers( + super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + true, + ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) - .count(); - assert_eq!(project_layers_unknown, 0); - assert_eq!(layers_unknown.effective_config().get("foo"), None); + .collect(); + assert_eq!(project_layers_unknown.len(), 1); + assert!( + project_layers_unknown[0].disabled_reason.is_some(), + "expected unknown-trust project layer to be disabled" + ); + assert_eq!( + project_layers_unknown[0].config.get("foo"), + Some(&TomlValue::String("child".to_string())) + ); + assert_eq!( + layers_unknown.effective_config().get("foo"), + Some(&TomlValue::String("user".to_string())) + ); + + Ok(()) +} + +#[tokio::test] +async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), "foo =").await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let cases = [ + ("untrusted", Some(TrustLevel::Untrusted)), + ("unknown", None), + ]; + + for (name, trust_level) in cases { + let codex_home = tmp.path().join(format!("home_{name}")); + tokio::fs::create_dir_all(&codex_home).await?; + let config_path = codex_home.join(CONFIG_TOML_FILE); + + if let Some(trust_level) = trust_level { + make_config_for_test(&codex_home, &project_root, trust_level, None).await?; + let config_contents = tokio::fs::read_to_string(&config_path).await?; + tokio::fs::write(&config_path, format!("foo = \"user\"\n{config_contents}")).await?; + } else { + tokio::fs::write(&config_path, "foo = \"user\"\n").await?; + } + + let layers = load_config_layers_state( + &codex_home, + Some(cwd.clone()), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers: Vec<_> = layers + .get_layers( + super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + true, + ) + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!( + project_layers.len(), + 1, + "expected one project layer for {name}" + ); + assert!( + project_layers[0].disabled_reason.is_some(), + "expected {name} project layer to be disabled" + ); + assert_eq!( + project_layers[0].config, + TomlValue::Table(toml::map::Map::new()) + ); + assert_eq!( + layers.effective_config().get("foo"), + Some(&TomlValue::String("user".to_string())) + ); + } Ok(()) } diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index e3a6751b55..61c0070239 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -227,7 +227,9 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result anyhow::Result<()> { + let project_dir = tempdir()?; + let policy_dir = project_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&policy_dir)?; + fs::write( + policy_dir.join("disabled.rules"), + r#"prefix_rule(pattern=["ls"], decision="forbidden")"#, + )?; + + let project_dot_codex_folder = AbsolutePathBuf::from_absolute_path(project_dir.path())?; + let layers = vec![ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex_folder, + }, + TomlValue::Table(Default::default()), + "trust disabled", + )]; + let config_stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let policy = load_exec_policy(&config_stack).await?; + + assert_eq!( + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["ls".to_string()], + decision: Decision::Forbidden, + justification: None, + }], + }, + policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow) + ); + + Ok(()) + } + #[tokio::test] async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { let user_dir = tempdir()?; diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index a408f8ffcc..5e2a0d5d50 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -105,7 +105,7 @@ pub enum Feature { Connectors, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. Steer, - /// Enable collaboration modes (Plan, Pair Programming, Execute). + /// Enable collaboration modes (Plan, Code, Pair Programming, Execute). CollaborationModes, /// Use the Responses API WebSocket transport for OpenAI by default. ResponsesWebsockets, @@ -434,11 +434,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Collab, key: "collab", - stage: Stage::Experimental { - name: "Multi-agents", - menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).", - announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!", - }, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 41d33a4ce4..2042e330ef 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -15,6 +15,7 @@ pub mod codex; mod codex_thread; mod compact_remote; pub use codex_thread::CodexThread; +pub use codex_thread::ThreadConfigSnapshot; mod agent; mod codex_delegate; mod command_safety; diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index 39d725bcd6..b7efe156f1 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -1,42 +1,64 @@ -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; +use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ReasoningEffort; const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md"); +const COLLABORATION_MODE_CODE: &str = include_str!("../../templates/collaboration_mode/code.md"); const COLLABORATION_MODE_PAIR_PROGRAMMING: &str = include_str!("../../templates/collaboration_mode/pair_programming.md"); const COLLABORATION_MODE_EXECUTE: &str = include_str!("../../templates/collaboration_mode/execute.md"); -pub(super) fn builtin_collaboration_mode_presets() -> Vec { - vec![plan_preset(), pair_programming_preset(), execute_preset()] +pub(super) fn builtin_collaboration_mode_presets() -> Vec { + vec![ + plan_preset(), + code_preset(), + pair_programming_preset(), + execute_preset(), + ] } #[cfg(any(test, feature = "test-support"))] -pub fn test_builtin_collaboration_mode_presets() -> Vec { +pub fn test_builtin_collaboration_mode_presets() -> Vec { builtin_collaboration_mode_presets() } -fn plan_preset() -> CollaborationMode { - CollaborationMode::Plan(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()), - }) +fn plan_preset() -> CollaborationModeMask { + CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some("gpt-5.2-codex".to_string()), + reasoning_effort: Some(Some(ReasoningEffort::High)), + developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())), + } } -fn pair_programming_preset() -> CollaborationMode { - CollaborationMode::PairProgramming(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()), - }) +fn code_preset() -> CollaborationModeMask { + CollaborationModeMask { + name: "Code".to_string(), + mode: Some(ModeKind::Code), + model: Some("gpt-5.2-codex".to_string()), + reasoning_effort: Some(Some(ReasoningEffort::Medium)), + developer_instructions: Some(Some(COLLABORATION_MODE_CODE.to_string())), + } } -fn execute_preset() -> CollaborationMode { - CollaborationMode::Execute(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()), - }) +fn pair_programming_preset() -> CollaborationModeMask { + CollaborationModeMask { + name: "Pair Programming".to_string(), + mode: Some(ModeKind::PairProgramming), + model: Some("gpt-5.2-codex".to_string()), + reasoning_effort: Some(Some(ReasoningEffort::Medium)), + developer_instructions: Some(Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string())), + } +} + +fn execute_preset() -> CollaborationModeMask { + CollaborationModeMask { + name: "Execute".to_string(), + mode: Some(ModeKind::Execute), + model: Some("gpt-5.2-codex".to_string()), + reasoning_effort: Some(Some(ReasoningEffort::High)), + developer_instructions: Some(Some(COLLABORATION_MODE_EXECUTE.to_string())), + } } diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 7210e1c62d..58049b4747 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -14,7 +14,7 @@ use crate::models_manager::model_presets::builtin_model_presets; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_app_server_protocol::AuthMode; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; @@ -30,9 +30,6 @@ use tracing::error; const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); -const OPENAI_DEFAULT_API_MODEL: &str = "gpt-5.2-codex"; -const OPENAI_DEFAULT_CHATGPT_MODEL: &str = "gpt-5.2-codex"; -const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; /// Strategy for refreshing available models. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -94,7 +91,7 @@ impl ModelsManager { /// List collaboration mode presets. /// /// Returns a static set of presets seeded with the configured model. - pub fn list_collaboration_modes(&self) -> Vec { + pub fn list_collaboration_modes(&self) -> Vec { builtin_collaboration_mode_presets() } @@ -110,7 +107,7 @@ impl ModelsManager { /// Get the model identifier to use, refreshing according to the specified strategy. /// /// If `model` is provided, returns it directly. Otherwise selects the default based on - /// auth mode and available models (prefers `codex-auto-balanced` for ChatGPT auth). + /// auth mode and available models. pub async fn get_default_model( &self, model: &Option, @@ -126,20 +123,14 @@ impl ModelsManager { { error!("failed to refresh available models: {err}"); } - // if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model - let auth_mode = self.auth_manager.get_auth_mode(); let remote_models = self.get_remote_models(config).await; - if auth_mode == Some(AuthMode::ChatGPT) { - let has_auto_balanced = self - .build_available_models(remote_models) - .iter() - .any(|model| model.model == CODEX_AUTO_BALANCED_MODEL && model.show_in_picker); - if has_auto_balanced { - return CODEX_AUTO_BALANCED_MODEL.to_string(); - } - return OPENAI_DEFAULT_CHATGPT_MODEL.to_string(); - } - OPENAI_DEFAULT_API_MODEL.to_string() + let available = self.build_available_models(remote_models); + available + .iter() + .find(|model| model.is_default) + .or_else(|| available.first()) + .map(|model| model.model.clone()) + .unwrap_or_default() } // todo(aibrahim): look if we can tighten it to pub(crate) @@ -336,7 +327,16 @@ impl ModelsManager { #[cfg(any(test, feature = "test-support"))] /// Get model identifier without consulting remote state or cache. pub fn get_model_offline(model: Option<&str>) -> String { - model.unwrap_or(OPENAI_DEFAULT_CHATGPT_MODEL).to_string() + if let Some(model) = model { + return model.to_string(); + } + let presets = builtin_model_presets(None); + presets + .iter() + .find(|preset| preset.show_in_picker) + .or_else(|| presets.first()) + .map(|preset| preset.model.clone()) + .unwrap_or_default() } #[cfg(any(test, feature = "test-support"))] diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index e571dccc68..14b2addc55 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -72,6 +72,7 @@ struct HeadTailSummary { /// Hard cap to bound worst‑case work per request. const MAX_SCAN_FILES: usize = 10000; const HEAD_RECORD_LIMIT: usize = 10; +const USER_EVENT_SCAN_LIMIT: usize = 200; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThreadSortKey { @@ -943,14 +944,20 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result = serde_json::from_str(trimmed); let Ok(rollout_line) = parsed else { continue }; @@ -963,9 +970,11 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { @@ -973,7 +982,9 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result std::io::Result<()> { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let dt = PrimitiveDateTime::parse(ts_str, format) + .unwrap() + .assume_utc(); + let dir = root + .join("sessions") + .join(format!("{:04}", dt.year())) + .join(format!("{:02}", u8::from(dt.month()))) + .join(format!("{:02}", dt.day())); + fs::create_dir_all(&dir)?; + + let filename = format!("rollout-{ts_str}-{uuid}.jsonl"); + let file_path = dir.join(filename); + let mut file = File::create(file_path)?; + + for i in 0..meta_lines_before_user { + let id = if i == 0 { + uuid + } else { + Uuid::from_u128(100 + i as u128) + }; + let payload = serde_json::json!({ + "id": id, + "timestamp": ts_str, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + "model_provider": "test-provider", + }); + let meta = serde_json::json!({ + "timestamp": ts_str, + "type": "session_meta", + "payload": payload, + }); + writeln!(file, "{meta}")?; + } + + let user_event = serde_json::json!({ + "timestamp": ts_str, + "type": "event_msg", + "payload": {"type": "user_message", "message": "Hello from user", "kind": "plain"} + }); + writeln!(file, "{user_event}")?; + + let times = FileTimes::new().set_modified(dt.into()); + file.set_times(times)?; + Ok(()) +} + fn write_session_file_with_meta_payload( root: &Path, ts_str: &str, @@ -539,6 +596,31 @@ async fn test_pagination_cursor() { assert_eq!(page3, expected_page3); } +#[tokio::test] +async fn test_list_threads_scans_past_head_for_user_event() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let uuid = Uuid::from_u128(99); + let ts = "2025-05-01T10-30-00"; + write_session_file_with_delayed_user_event(home, ts, uuid, 12).unwrap(); + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 10, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); + + assert_eq!(page.items.len(), 1); +} + #[tokio::test] async fn test_get_thread_contents() { let temp = TempDir::new().unwrap(); diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 44e732df61..b578c4232f 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -28,6 +28,7 @@ pub struct ShellSnapshot { const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 days retention. const SNAPSHOT_DIR: &str = "shell_snapshots"; +const EXCLUDED_EXPORT_VARS: &[&str] = &["PWD", "OLDPWD"]; impl ShellSnapshot { pub fn start_snapshotting( @@ -138,9 +139,9 @@ async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Resu async fn capture_snapshot(shell: &Shell) -> Result { let shell_type = shell.shell_type.clone(); match shell_type { - ShellType::Zsh => run_shell_script(shell, zsh_snapshot_script()).await, - ShellType::Bash => run_shell_script(shell, bash_snapshot_script()).await, - ShellType::Sh => run_shell_script(shell, sh_snapshot_script()).await, + ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script()).await, + ShellType::Bash => run_shell_script(shell, &bash_snapshot_script()).await, + ShellType::Sh => run_shell_script(shell, &sh_snapshot_script()).await, ShellType::PowerShell => run_shell_script(shell, powershell_snapshot_script()).await, ShellType::Cmd => bail!("Shell snapshotting is not yet supported for {shell_type:?}"), } @@ -202,8 +203,13 @@ async fn run_script_with_timeout( Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } -fn zsh_snapshot_script() -> &'static str { - r##"if [[ -n "$ZDOTDIR" ]]; then +fn excluded_exports_regex() -> String { + EXCLUDED_EXPORT_VARS.join("|") +} + +fn zsh_snapshot_script() -> String { + let excluded = excluded_exports_regex(); + let script = r##"if [[ -n "$ZDOTDIR" ]]; then rc="$ZDOTDIR/.zshrc" else rc="$HOME/.zshrc" @@ -229,6 +235,9 @@ export_lines=$(export -p | awk ' name=line sub(/^(export|declare -x|typeset -x) /, "", name) sub(/=.*/, "", name) + if (name ~ /^(EXCLUDED_EXPORTS)$/) { + next + } if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { print line } @@ -238,11 +247,13 @@ print "# exports $export_count" if [[ -n "$export_lines" ]]; then print -r -- "$export_lines" fi -"## +"##; + script.replace("EXCLUDED_EXPORTS", &excluded) } -fn bash_snapshot_script() -> &'static str { - r##"if [ -z "$BASH_ENV" ] && [ -r "$HOME/.bashrc" ]; then +fn bash_snapshot_script() -> String { + let excluded = excluded_exports_regex(); + let script = r##"if [ -z "$BASH_ENV" ] && [ -r "$HOME/.bashrc" ]; then . "$HOME/.bashrc" fi echo '# Snapshot file' @@ -268,6 +279,9 @@ export_lines=$(export -p | awk ' name=line sub(/^(export|declare -x|typeset -x) /, "", name) sub(/=.*/, "", name) + if (name ~ /^(EXCLUDED_EXPORTS)$/) { + next + } if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { print line } @@ -277,11 +291,13 @@ echo "# exports $export_count" if [ -n "$export_lines" ]; then printf '%s\n' "$export_lines" fi -"## +"##; + script.replace("EXCLUDED_EXPORTS", &excluded) } -fn sh_snapshot_script() -> &'static str { - r##"if [ -n "$ENV" ] && [ -r "$ENV" ]; then +fn sh_snapshot_script() -> String { + let excluded = excluded_exports_regex(); + let script = r##"if [ -n "$ENV" ] && [ -r "$ENV" ]; then . "$ENV" fi echo '# Snapshot file' @@ -320,6 +336,9 @@ if export -p >/dev/null 2>&1; then name=line sub(/^(export|declare -x|typeset -x) /, "", name) sub(/=.*/, "", name) + if (name ~ /^(EXCLUDED_EXPORTS)$/) { + next + } if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { print line } @@ -334,13 +353,14 @@ else echo "# exports $export_count" env | sort | while IFS='=' read -r key value; do case "$key" in - ""|[0-9]*|*[!A-Za-z0-9_]* ) continue ;; + ""|[0-9]*|*[!A-Za-z0-9_]*|EXCLUDED_EXPORTS) continue ;; esac escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") printf "export %s='%s'\n" "$key" "$escaped" done fi -"## +"##; + script.replace("EXCLUDED_EXPORTS", &excluded) } fn powershell_snapshot_script() -> &'static str { @@ -494,6 +514,7 @@ mod tests { .arg(bash_snapshot_script()) .env("BASH_ENV", "/dev/null") .env("VALID_NAME", "ok") + .env("PWD", "/tmp/stale") .env("NEXTEST_BIN_EXE_codex-write-config-schema", "/path/to/bin") .env("BAD-NAME", "broken") .output()?; @@ -502,6 +523,7 @@ mod tests { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("VALID_NAME")); + assert!(!stdout.contains("PWD=/tmp/stale")); assert!(!stdout.contains("NEXTEST_BIN_EXE_codex-write-config-schema")); assert!(!stdout.contains("BAD-NAME")); diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 1e02926572..09c988a15b 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,5 +1,6 @@ use crate::config::Config; use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; use crate::skills::model::SkillError; use crate::skills::model::SkillInterface; use crate::skills::model::SkillLoadOutcome; @@ -133,7 +134,9 @@ where fn skill_roots_from_layer_stack_inner(config_layer_stack: &ConfigLayerStack) -> Vec { let mut roots = Vec::new(); - for layer in config_layer_stack.layers_high_to_low() { + for layer in + config_layer_stack.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst, true) + { let Some(config_folder) = layer.config_folder() else { continue; }; @@ -575,11 +578,17 @@ mod tests { } async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config { + let trust_root = cwd + .ancestors() + .find(|ancestor| ancestor.join(".git").exists()) + .map(Path::to_path_buf) + .unwrap_or_else(|| cwd.clone()); + fs::write( codex_home.path().join(CONFIG_TOML_FILE), toml::to_string(&ConfigToml { projects: Some(HashMap::from([( - cwd.to_string_lossy().to_string(), + trust_root.to_string_lossy().to_string(), ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, @@ -663,6 +672,59 @@ mod tests { Ok(()) } + #[test] + fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let user_folder = tmp.path().join("home/codex"); + fs::create_dir_all(&user_folder)?; + + let project_root = tmp.path().join("repo"); + let dot_codex = project_root.join(".codex"); + fs::create_dir_all(&dot_codex)?; + + let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; + let project_dot_codex = AbsolutePathBuf::from_absolute_path(&dot_codex)?; + + let layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + TomlValue::Table(toml::map::Map::new()), + "marked untrusted", + ), + ]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let got = skill_roots_from_layer_stack(&stack) + .into_iter() + .map(|root| (root.scope, root.path)) + .collect::>(); + + assert_eq!( + got, + vec![ + (SkillScope::Repo, dot_codex.join("skills")), + (SkillScope::User, user_folder.join("skills")), + ( + SkillScope::System, + user_folder.join("skills").join(".system") + ), + ] + ); + + Ok(()) + } + fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { write_skill_at(&codex_home.path().join("skills"), dir, name, description) } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 70d8cb7900..bc5cd4f19e 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -19,7 +19,7 @@ use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::skills::SkillsManager; use codex_protocol::ThreadId; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; @@ -158,7 +158,7 @@ impl ThreadManager { .await } - pub fn list_collaboration_modes(&self) -> Vec { + pub fn list_collaboration_modes(&self) -> Vec { self.state.models_manager.list_collaboration_modes() } @@ -314,11 +314,22 @@ impl ThreadManagerState { config: Config, agent_control: AgentControl, ) -> CodexResult { - self.spawn_thread( + self.spawn_new_thread_with_source(config, agent_control, self.session_source.clone()) + .await + } + + pub(crate) async fn spawn_new_thread_with_source( + &self, + config: Config, + agent_control: AgentControl, + session_source: SessionSource, + ) -> CodexResult { + self.spawn_thread_with_source( config, InitialHistory::New, Arc::clone(&self.auth_manager), agent_control, + session_source, ) .await } @@ -330,6 +341,24 @@ impl ThreadManagerState { initial_history: InitialHistory, auth_manager: Arc, agent_control: AgentControl, + ) -> CodexResult { + self.spawn_thread_with_source( + config, + initial_history, + auth_manager, + agent_control, + self.session_source.clone(), + ) + .await + } + + pub(crate) async fn spawn_thread_with_source( + &self, + config: Config, + initial_history: InitialHistory, + auth_manager: Arc, + agent_control: AgentControl, + session_source: SessionSource, ) -> CodexResult { let CodexSpawnOk { codex, thread_id, .. @@ -339,7 +368,7 @@ impl ThreadManagerState { Arc::clone(&self.models_manager), Arc::clone(&self.skills_manager), initial_history, - self.session_source.clone(), + session_source, agent_control, ) .await?; diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 56463e644a..83ee53905b 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -78,6 +78,11 @@ impl ToolHandler for CollabHandler { mod spawn { use super::*; use crate::agent::AgentRole; + use crate::agent::MAX_THREAD_SPAWN_DEPTH; + use crate::agent::exceeds_thread_spawn_depth_limit; + use crate::agent::next_thread_spawn_depth; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::SubAgentSource; use std::sync::Arc; #[derive(Debug, Deserialize)] @@ -105,6 +110,13 @@ mod spawn { "Empty message can't be sent to an agent".to_string(), )); } + let session_source = turn.client.get_session_source(); + let child_depth = next_thread_spawn_depth(&session_source); + if exceeds_thread_spawn_depth_limit(child_depth) { + return Err(FunctionCallError::RespondToModel(format!( + "agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}" + ))); + } session .send_event( &turn, @@ -125,7 +137,14 @@ mod spawn { let result = session .services .agent_control - .spawn_agent(config, prompt.clone()) + .spawn_agent( + config, + prompt.clone(), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: session.conversation_id, + depth: child_depth, + })), + ) .await .map_err(collab_spawn_error); let (new_thread_id, status) = match &result { @@ -573,7 +592,6 @@ fn build_agent_spawn_config( config.model_reasoning_summary = turn.client.get_reasoning_summary(); config.developer_instructions = turn.developer_instructions.clone(); config.compact_prompt = turn.compact_prompt.clone(); - config.user_instructions = turn.user_instructions.clone(); config.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); config.cwd = turn.cwd.clone(); @@ -597,13 +615,17 @@ mod tests { use super::*; use crate::CodexAuth; use crate::ThreadManager; + use crate::agent::MAX_THREAD_SPAWN_DEPTH; use crate::built_in_model_providers; + use crate::client::ModelClient; use crate::codex::make_session_and_context; use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; use crate::protocol::AskForApproval; use crate::protocol::Op; use crate::protocol::SandboxPolicy; + use crate::protocol::SessionSource; + use crate::protocol::SubAgentSource; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; @@ -723,6 +745,45 @@ mod tests { ); } + #[tokio::test] + async fn spawn_agent_rejects_when_depth_limit_exceeded() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: session.conversation_id, + depth: MAX_THREAD_SPAWN_DEPTH, + }); + turn.client = ModelClient::new( + turn.client.config(), + Some(session.services.auth_manager.clone()), + turn.client.get_model_info(), + turn.client.get_otel_manager(), + turn.client.get_provider(), + turn.client.get_reasoning_effort(), + turn.client.get_reasoning_summary(), + session.conversation_id, + session_source, + ); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": "hello"})), + ); + let Err(err) = CollabHandler.handle(invocation).await else { + panic!("spawn should fail when depth limit exceeded"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel(format!( + "agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}" + )) + ); + } + #[tokio::test] async fn send_input_rejects_empty_message() { let (session, turn) = make_session_and_context().await; @@ -1073,7 +1134,6 @@ mod tests { }; turn.developer_instructions = Some("dev".to_string()); turn.compact_prompt = Some("compact".to_string()); - turn.user_instructions = Some("user".to_string()); turn.shell_environment_policy = ShellEnvironmentPolicy { use_profile: true, ..ShellEnvironmentPolicy::default() @@ -1093,7 +1153,6 @@ mod tests { expected.model_reasoning_summary = turn.client.get_reasoning_summary(); expected.developer_instructions = turn.developer_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); - expected.user_instructions = turn.user_instructions.clone(); expected.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); expected.cwd = turn.cwd.clone(); @@ -1107,4 +1166,31 @@ mod tests { .expect("sandbox policy set"); assert_eq!(config, expected); } + + #[tokio::test] + async fn build_agent_spawn_config_preserves_base_user_instructions() { + let (session, mut turn) = make_session_and_context().await; + let session_source = turn.client.get_session_source(); + let mut base_config = (*turn.client.config()).clone(); + base_config.user_instructions = Some("base-user".to_string()); + turn.user_instructions = Some("resolved-user".to_string()); + turn.client = ModelClient::new( + Arc::new(base_config.clone()), + Some(session.services.auth_manager.clone()), + turn.client.get_model_info(), + turn.client.get_otel_manager(), + turn.client.get_provider(), + turn.client.get_reasoning_effort(), + turn.client.get_reasoning_summary(), + session.conversation_id, + session_source, + ); + let base_instructions = BaseInstructions { + text: "base".to_string(), + }; + + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + + assert_eq!(config.user_instructions, base_config.user_instructions); + } } diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index aca43a1d12..d2c55b08df 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -7,7 +7,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::request_user_input::RequestUserInputArgs; pub struct RequestUserInputHandler; @@ -36,9 +36,9 @@ impl ToolHandler for RequestUserInputHandler { } }; - let disallowed_mode = match session.collaboration_mode().await { - CollaborationMode::Execute(_) => Some("Execute"), - CollaborationMode::Custom(_) => Some("Custom"), + let disallowed_mode = match session.collaboration_mode().await.mode { + ModeKind::Execute => Some("Execute"), + ModeKind::Custom => Some("Custom"), _ => None, }; if let Some(mode_name) = disallowed_mode { diff --git a/codex-rs/core/templates/collab/experimental_prompt.md b/codex-rs/core/templates/collab/experimental_prompt.md index 9763592e27..c6cd6f7ac4 100644 --- a/codex-rs/core/templates/collab/experimental_prompt.md +++ b/codex-rs/core/templates/collab/experimental_prompt.md @@ -13,6 +13,3 @@ This feature must be used wisely. For simple or straightforward tasks, you don't * When you're done with a sub-agent, don't forget to close it using `close_agent`. * Be careful on the `timeout_ms` parameter you choose for `wait`. It should be wisely scaled. * Sub-agents have access to the same set of tools as you do so you must tell them if they are allowed to spawn sub-agents themselves or not. - -## Global -You're name is Batman diff --git a/codex-rs/core/templates/collaboration_mode/code.md b/codex-rs/core/templates/collaboration_mode/code.md new file mode 100644 index 0000000000..2294e61993 --- /dev/null +++ b/codex-rs/core/templates/collaboration_mode/code.md @@ -0,0 +1 @@ +you are now in code mode. diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 32778d4408..7d78645de2 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -2,7 +2,7 @@ You work in **two phases**: -- **PHASE 1 — Understand user intent**: Align on what the user is trying to accomplish and what “success” means. Focus on intent, scope, constraints, and context. +- **PHASE 1 — Understand user intent**: Align on what the user is trying to accomplish and what “success” means. Focus on intent, scope, constraints, and preference tradeoffs. - **PHASE 2 — Technical spec & implementation plan**: Convert the intent into a decision‑complete technical spec and an implementation plan detailed enough that another agent could execute with minimal follow‑ups. --- @@ -21,11 +21,24 @@ Constraints: --- -## Evidence‑first exploration (precondition to asking) +## Two types of uncertainty (treat differently) -When a repo / codebase / workspace is available (or implied), you MUST attempt to resolve “where/how is X defined?” and other discoverable questions by **exploring first**. +### Type 1 — Discoverable facts (repo/system truth) +Examples: “Where is app‑server 2 defined?”, “Which config sets turn duration?”, “Which service emits this metric?” -Before calling `request_user_input`, do a quick investigation pass: +Rule: **Evidence-first exploration applies.** Don’t ask the user until you’ve searched. + +### Type 2 — Preferences & tradeoffs (product and engineering intent) + +Rule: **Ask early** These are often *not discoverable* and should not be silently assumed when multiple approaches are viable. + +--- + +## Evidence‑first exploration (precondition to asking discoverable questions) + +When a repo / codebase / workspace is available (or implied), you MUST attempt to resolve discoverable questions by **exploring first**. + +Before calling `request_user_input` for a discoverable fact, do a quick investigation pass: - Run at least **2 targeted searches** (exact match + a likely variant/synonym). - Check the most likely “source of truth” surfaces (service manifests, infra configs, env/config files, entrypoints, schemas/types/constants). @@ -42,7 +55,23 @@ If you must ask, incorporate what you already found: --- -## No‑trivia rule for questions +## Preference capture (you SHOULD ask when it changes the plan) + +If there are **multiple reasonable implementation approaches** with meaningful tradeoffs, you SHOULD ask the user to choose their preference even if you could assume a default. + +Treat tradeoff choice as **high-impact** unless the user explicitly said: +- “Use your best judgement,” or +- “Pick whatever is simplest,” or +- “I don’t care—ship fast.” + +When asking a preference question: +- Provide **2–4 mutually exclusive options**. +- Include a **recommended default** that matches the user’s apparent goals. +- If the user doesn’t answer, proceed with the recommended option and record it as an assumption. + +--- + +## No‑trivia rule for questions (guardrail) You MUST NOT ask questions whose answers are likely to be found by: - repo text search, @@ -52,32 +81,39 @@ unless you already attempted those and can summarize what you found. Every `request_user_input` question must: - materially change an implementation decision, OR -- disambiguate between **concrete candidates** you already found. +- disambiguate between **concrete candidates** you already found, OR +- capture a **preference/tradeoff** that is not discoverable from the repo. --- ## PHASE 1 — Understand user intent ### Purpose -Identify what the user actually wants, what matters most, and what constraints shape the solution. +Identify what the user actually wants, what matters most, and what constraints + preferences shape the solution. ### Phase 1 principles -- State what you think the user cares about. Reflect inferred priorities (speed vs quality, prototype vs production, etc.). +- State what you think the user cares about (speed vs quality, prototype vs production, etc.). - Think out loud briefly when it helps weigh tradeoffs. - Use reasonable suggestions with explicit assumptions; make it easy to accept/override. -- Ask fewer, better questions. Only ask what materially changes the spec/plan. +- Ask fewer, better questions. Ask only what materially changes the spec/plan OR captures a real tradeoff. - Think ahead: propose helpful suggestions the user may need (testing, debug mode, observability, migration path). ### Phase 1 exit criteria (Intent gate) Before moving to Phase 2, ensure you have either a **user answer** OR an **explicit assumption** for: + +**Intent basics** - Primary goal + success criteria (how we know it worked) - Primary user / audience - In-scope and out-of-scope - Constraints (time, budget, platform, security/compliance) - Current context (what exists today: code/system/data) -If any missing item materially changes the plan, ask via `request_user_input`. -If unknown but low-impact, assume a sensible default and proceed. +**Preference profile (don’t silently assume if unclear and high-impact)** +- Risk posture: prototype vs production quality bar +- Tradeoff priority: ship fast vs robust/maintainable +- Compatibility expectations: backward compatibility / migrations / downtime tolerance (if relevant) + +Use `request_user_input` to deeply understand the user's intent after exploring your environment. --- @@ -93,6 +129,7 @@ Before finalizing the plan, ensure you’ve pinned down (answer or assumption): - Data flow + key edge cases / failure modes - Testing + acceptance criteria - Rollout/monitoring expectations +- Any key preference/tradeoff decisions (and rationale) If something is high-impact and unknown, ask via `request_user_input`. Otherwise assume defaults and proceed. @@ -100,7 +137,10 @@ If something is high-impact and unknown, ask via `request_user_input`. Otherwise ## Using `request_user_input` in Plan Mode -Use `request_user_input` only when you are genuinely blocked on a decision that materially changes the plan AND the decision cannot be resolved via evidence-first workspace exploration. +Use `request_user_input` when either: +1) You are genuinely blocked on a decision that materially changes the plan and cannot be resolved via evidence-first exploration, OR +2) There is a meaningful **preference/tradeoff** the user should choose among. +3) When an answer is skipped, assume the recommended path. Rules: - **Default to options** when there are ≤ 4 common outcomes; include a **recommended** option. @@ -125,42 +165,28 @@ Do **not** use `request_user_input` to ask: "header": "Migrate", "question": "Enable the database migration in this release?", "options": [ - { - "label": "Yes (Recommended)", - "description": "Ship the migration with this rollout." - }, - { - "label": "No", - "description": "Defer the migration to a later release." - } + { "label": "Yes (Recommended)", "description": "Ship the migration with this rollout." }, + { "label": "No", "description": "Defer the migration to a later release." } ] } ] } ```` -**2) Choice with free-form** +**2) Preference/tradeoff question (recommended + options)** ```json { "questions": [ { - "id": "cache_strategy", - "header": "Cache", - "question": "Which cache strategy should we implement?", + "id": "tradeoff_priority", + "header": "Tradeoff", + "question": "Which priority should guide the implementation?", "options": [ - { - "label": "Write-through (Recommended)", - "description": "Simpler consistency with predictable latency." - }, - { - "label": "Write-back", - "description": "Lower write latency but higher complexity." - }, - { - "label": "Other", - "description": "Provide a custom strategy or constraints." - } + { "label": "Ship fast (Recommended)", "description": "Minimal changes, pragmatic shortcuts, faster delivery." }, + { "label": "Robust & maintainable", "description": "Cleaner abstractions, more refactor, better long-term stability." }, + { "label": "Performance-first", "description": "Optimize latency/throughput even if complexity rises." }, + { "label": "Other", "description": "Specify a different priority or constraint." } ] } ] @@ -173,9 +199,9 @@ Do **not** use `request_user_input` to ask: { "questions": [ { - "id": "rollout_constraints", - "header": "Rollout", - "question": "Any rollout constraints or compliance requirements we must follow?" + "id": "acceptance_criteria", + "header": "Success", + "question": "What are the acceptance criteria or success metrics we should optimize for?" } ] } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index eb08de075c..902609afc5 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -23,6 +23,7 @@ use codex_core::protocol::SessionSource; use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::config_types::Verbosity; @@ -887,11 +888,14 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re .build(&server) .await?; - let collaboration_mode = CollaborationMode::Custom(Settings { - model: "gpt-5.1".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }; codex .submit(Op::UserTurn { diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3304111698..f7183b817e 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -4,6 +4,7 @@ use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; @@ -22,11 +23,14 @@ fn sse_completed(id: &str) -> String { } fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { - CollaborationMode::Custom(Settings { - model: "gpt-5.1".to_string(), - reasoning_effort: None, - developer_instructions: instructions.map(str::to_string), - }) + CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: None, + developer_instructions: instructions.map(str::to_string), + }, + } } fn developer_texts(input: &[Value]) -> Vec { @@ -418,7 +422,11 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { let mut builder = test_codex(); let initial = builder.build(&server).await?; - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let home = initial.home.clone(); let collab_text = "resume instructions"; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 230ab57840..1ecac3afe0 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -154,7 +154,7 @@ async fn summarize_context_three_requests_and_instructions() { session_configured, .. } = thread_manager.start_thread(config).await.unwrap(); - let rollout_path = session_configured.rollout_path; + let rollout_path = session_configured.rollout_path.expect("rollout path"); // 1) Normal user input – should hit server once. codex @@ -1237,7 +1237,11 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { }); let initial = builder.build(&server).await.unwrap(); let home = initial.home.clone(); - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); // A single over-limit completion should not auto-compact until the next user message. mount_sse_once( @@ -1429,7 +1433,7 @@ async fn auto_compact_persists_rollout_entries() { codex.submit(Op::Shutdown).await.unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - let rollout_path = session_configured.rollout_path; + let rollout_path = session_configured.rollout_path.expect("rollout path"); let text = std::fs::read_to_string(&rollout_path).unwrap_or_else(|e| { panic!( "failed to read rollout file {}: {e}", diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index a2cf73e110..2fc5ba53c2 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -230,7 +230,12 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ) .await?; let codex = harness.test().codex.clone(); - let rollout_path = harness.test().session_configured.rollout_path.clone(); + let rollout_path = harness + .test() + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let responses_mock = responses::mount_sse_once( harness.server(), diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index b7ce49abbe..f6047ac733 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -1013,7 +1013,7 @@ async fn compact_conversation(conversation: &Arc) { } async fn fetch_conversation_path(conversation: &Arc) -> std::path::PathBuf { - conversation.rollout_path() + conversation.rollout_path().expect("rollout path") } async fn resume_conversation( diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 2a9f604c3d..8698991846 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -80,7 +80,7 @@ async fn fork_thread_twice_drops_to_first_message() { } // Request history from the base conversation to obtain rollout path. - let base_path = codex.rollout_path(); + let base_path = codex.rollout_path().expect("rollout path"); // GetHistory flushes before returning the path; no wait needed. @@ -135,7 +135,7 @@ async fn fork_thread_twice_drops_to_first_message() { .await .expect("fork 1"); - let fork1_path = codex_fork1.rollout_path(); + let fork1_path = codex_fork1.rollout_path().expect("rollout path"); // GetHistory on fork1 flushed; the file is ready. let fork1_items = read_items(&fork1_path); @@ -154,7 +154,7 @@ async fn fork_thread_twice_drops_to_first_message() { .await .expect("fork 2"); - let fork2_path = codex_fork2.rollout_path(); + let fork2_path = codex_fork2.rollout_path().expect("rollout path"); // GetHistory on fork2 flushed; the file is ready. let fork1_items = read_items(&fork1_path); let fork1_user_inputs = find_user_input_positions(&fork1_items); diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index b6dad6a227..6aebe6630d 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -136,7 +136,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu codex.submit(Op::Shutdown).await?; wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await; - let rollout_path = codex.rollout_path(); + let rollout_path = codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let actual = find_user_message_with_image(&rollout_text) .expect("expected user message with input image in rollout"); @@ -217,7 +217,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> codex.submit(Op::Shutdown).await?; wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await; - let rollout_path = codex.rollout_path(); + let rollout_path = codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let actual = find_user_message_with_image(&rollout_text) .expect("expected user message with input image in rollout"); diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index 924fde733b..d995446864 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -9,6 +9,7 @@ use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -23,11 +24,14 @@ use std::time::Duration; use tempfile::TempDir; fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { - CollaborationMode::Custom(Settings { - model: "gpt-5.1".to_string(), - reasoning_effort: None, - developer_instructions: instructions.map(str::to_string), - }) + CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: None, + developer_instructions: instructions.map(str::to_string), + }, + } } fn collab_xml(text: &str) -> String { @@ -125,7 +129,7 @@ async fn override_turn_context_records_permissions_update() -> Result<()> { test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - let rollout_path = test.codex.rollout_path(); + let rollout_path = test.codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let developer_texts = rollout_developer_texts(&rollout_text); let approval_texts: Vec<&String> = developer_texts @@ -168,7 +172,7 @@ async fn override_turn_context_records_environment_update() -> Result<()> { test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - let rollout_path = test.codex.rollout_path(); + let rollout_path = test.codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let env_texts = rollout_environment_texts(&rollout_text); let new_cwd_text = new_cwd.path().display().to_string(); @@ -205,7 +209,7 @@ async fn override_turn_context_records_collaboration_update() -> Result<()> { test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - let rollout_path = test.codex.rollout_path(); + let rollout_path = test.codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let developer_texts = rollout_developer_texts(&rollout_text); let collab_text = collab_xml(collab_text); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 9a32a7d9f9..f3d9e8d47b 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -202,7 +202,11 @@ async fn resume_replays_permissions_messages() -> Result<()> { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); }); let initial = builder.build(&server).await?; - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let home = initial.home.clone(); initial @@ -280,7 +284,11 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); }); let initial = builder.build(&server).await?; - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let home = initial.home.clone(); initial diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index c7a3eb78a8..686428b21a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -12,6 +12,7 @@ use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; @@ -412,11 +413,14 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul let TestCodex { codex, .. } = test_codex().build(&server).await?; - let collaboration_mode = CollaborationMode::Custom(Settings { - model: "gpt-5.1".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }; codex .submit(Op::OverrideTurnContext { diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 59f6944577..74d3fc98f2 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -8,6 +8,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::request_user_input::RequestUserInputAnswer; @@ -132,11 +133,14 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, - collaboration_mode: Some(CollaborationMode::Plan(Settings { - model: session_configured.model.clone(), - reasoning_effort: None, - developer_instructions: None, - })), + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), personality: None, }) .await?; @@ -269,24 +273,26 @@ where #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_rejected_in_execute_mode() -> anyhow::Result<()> { - assert_request_user_input_rejected("Execute", |model| { - CollaborationMode::Execute(Settings { + assert_request_user_input_rejected("Execute", |model| CollaborationMode { + mode: ModeKind::Execute, + settings: Settings { model, reasoning_effort: None, developer_instructions: None, - }) + }, }) .await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_rejected_in_custom_mode() -> anyhow::Result<()> { - assert_request_user_input_rejected("Custom", |model| { - CollaborationMode::Custom(Settings { + assert_request_user_input_rejected("Custom", |model| CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { model, reasoning_effort: None, developer_instructions: None, - }) + }, }) .await } diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index d0718a334d..a7be42f430 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -26,7 +26,11 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { let initial = builder.build(&server).await?; let codex = Arc::clone(&initial.codex); let home = initial.home.clone(); - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let initial_sse = sse(vec![ ev_response_created("resp-initial"), @@ -85,7 +89,11 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> let initial = builder.build(&server).await?; let codex = Arc::clone(&initial.codex); let home = initial.home.clone(); - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let initial_sse = sse(vec![ ev_response_created("resp-initial"), @@ -143,7 +151,11 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { let initial = builder.build(&server).await?; let codex = Arc::clone(&initial.codex); let home = initial.home.clone(); - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let initial_sse = sse(vec![ ev_response_created("resp-initial"), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 3a66e320b6..df678f9e9d 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -120,7 +120,7 @@ async fn review_op_emits_lifecycle_and_review_output() { // Also verify that a user message with the header and a formatted finding // was recorded back in the parent session's rollout. - let path = codex.rollout_path(); + let path = codex.rollout_path().expect("rollout path"); let text = std::fs::read_to_string(&path).expect("read rollout file"); let mut saw_header = false; @@ -627,7 +627,7 @@ async fn review_input_isolated_from_parent_history() { assert_eq!(instructions, REVIEW_PROMPT); // Also verify that a user interruption note was recorded in the rollout. - let path = codex.rollout_path(); + let path = codex.rollout_path().expect("rollout path"); let text = std::fs::read_to_string(&path).expect("read rollout file"); let mut saw_interruption_message = false; for line in text.lines() { diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 0a4a08bd89..3e260ac24e 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -73,6 +73,7 @@ ignore = [ { id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map/starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is unmaintained; pulled in via rama-tls-rustls used by codex-network-proxy; no safe upgrade until rama removes the dependency" }, # TODO(joshka, nornagon): remove this exception when once we update the ratatui fork to a version that uses lru 0.13+. { id = "RUSTSEC-2026-0002", reason = "lru 0.12.5 is pulled in via ratatui fork; cannot upgrade until the fork is updated" }, ] diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 19e1d91581..1aa5aa12ae 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -101,7 +101,8 @@ Each response yields: Fetch the built-in collaboration mode presets with `collaborationMode/list`. This endpoint does not accept pagination and returns the full list in one response: -- `data` – ordered list of collaboration mode presets +- `data` – ordered list of collaboration mode masks (partial settings to apply on top of the base mode) + - For tri-state fields like `reasoning_effort` and `developer_instructions`, omit the field to keep the current value, set it to `null` to clear it, or set a concrete value to update it. ## Event stream diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d5fb0f6ed2..441041aa68 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -28,6 +28,8 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; +use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::format_config_error_with_source; use codex_core::git_info::get_git_repo_root; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::protocol::AskForApproval; @@ -162,7 +164,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any { Ok(config_toml) => config_toml, Err(err) => { - eprintln!("Error loading config.toml: {err}"); + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } std::process::exit(1); } } @@ -216,6 +229,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_apply_patch_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, + ephemeral: None, additional_writable_roots: add_dir, }; @@ -297,7 +311,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; - if !skip_git_repo_check && get_git_repo_root(&default_cwd).is_none() { + // When --yolo (dangerously_bypass_approvals_and_sandbox) is set, also skip the git repo check + // since the user is explicitly running in an externally sandboxed environment. + if !skip_git_repo_check + && !dangerously_bypass_approvals_and_sandbox + && get_git_repo_root(&default_cwd).is_none() + { eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified."); std::process::exit(1); } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e0b2509667..43de1f95f8 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -85,7 +85,7 @@ fn session_configured_produces_thread_started_event() { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path, + rollout_path: Some(rollout_path), }), ); let out = ep.collect_thread_events(&ev); diff --git a/codex-rs/execpolicy/src/error.rs b/codex-rs/execpolicy/src/error.rs index 9664e71a5c..25f9227210 100644 --- a/codex-rs/execpolicy/src/error.rs +++ b/codex-rs/execpolicy/src/error.rs @@ -3,6 +3,24 @@ use thiserror::Error; pub type Result = std::result::Result; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextPosition { + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorLocation { + pub path: String, + pub range: TextRange, +} + #[derive(Debug, Error)] pub enum Error { #[error("invalid decision: {0}")] @@ -26,3 +44,27 @@ pub enum Error { #[error("starlark error: {0}")] Starlark(StarlarkError), } + +impl Error { + pub fn location(&self) -> Option { + match self { + Error::Starlark(err) => err.span().map(|span| { + let resolved = span.resolve_span(); + ErrorLocation { + path: span.filename().to_string(), + range: TextRange { + start: TextPosition { + line: resolved.begin.line + 1, + column: resolved.begin.column + 1, + }, + end: TextPosition { + line: resolved.end.line + 1, + column: resolved.end.column + 1, + }, + }, + } + }), + _ => None, + } + } +} diff --git a/codex-rs/execpolicy/src/lib.rs b/codex-rs/execpolicy/src/lib.rs index 31062f1cb6..a9453f28d3 100644 --- a/codex-rs/execpolicy/src/lib.rs +++ b/codex-rs/execpolicy/src/lib.rs @@ -10,7 +10,10 @@ pub use amend::AmendError; pub use amend::blocking_append_allow_prefix_rule; pub use decision::Decision; pub use error::Error; +pub use error::ErrorLocation; pub use error::Result; +pub use error::TextPosition; +pub use error::TextRange; pub use execpolicycheck::ExecPolicyCheckCommand; pub use parser::PolicyParser; pub use policy::Evaluation; diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index b7c66592ff..2069db69d0 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -267,7 +267,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }), }; @@ -307,7 +307,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; let event = Event { id: "1".to_string(), @@ -371,7 +371,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; let event = Event { id: "1".to_string(), diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml new file mode 100644 index 0000000000..2d303b1c95 --- /dev/null +++ b/codex-rs/network-proxy/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "codex-network-proxy" +edition = "2024" +version = { workspace = true } +license.workspace = true + +[[bin]] +name = "codex-network-proxy" +path = "src/main.rs" + +[lib] +name = "codex_network_proxy" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-app-server-protocol = { workspace = true } +codex-core = { workspace = true } +codex-utils-absolute-path = { workspace = true } +globset = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +time = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["fmt"] } +url = { workspace = true } +rama-core = { version = "=0.3.0-alpha.4" } +rama-http = { version = "=0.3.0-alpha.4" } +rama-http-backend = { version = "=0.3.0-alpha.4", features = ["tls"] } +rama-net = { version = "=0.3.0-alpha.4", features = ["http", "tls"] } +rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] } +rama-tls-boring = { version = "=0.3.0-alpha.4", features = ["http"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[target.'cfg(target_family = "unix")'.dependencies] +rama-unix = { version = "=0.3.0-alpha.4" } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md new file mode 100644 index 0000000000..1d19a92a68 --- /dev/null +++ b/codex-rs/network-proxy/README.md @@ -0,0 +1,169 @@ +# codex-network-proxy + +`codex-network-proxy` is Codex's local network policy enforcement proxy. It runs: + +- an HTTP proxy (default `127.0.0.1:3128`) +- an admin HTTP API (default `127.0.0.1:8080`) + +It enforces an allow/deny policy and a "limited" mode intended for read-only network access. + +## Quickstart + +### 1) Configure + +`codex-network-proxy` reads from Codex's merged `config.toml` (via `codex-core` config loading). + +Example config: + +```toml +[network_proxy] +enabled = true +proxy_url = "http://127.0.0.1:3128" +admin_url = "http://127.0.0.1:8080" +# When `enabled` is false, the proxy no-ops and does not bind listeners. +# When true, respect HTTP(S)_PROXY/ALL_PROXY for upstream requests (HTTP(S) proxies only), +# including CONNECT tunnels in full mode. +allow_upstream_proxy = false +# By default, non-loopback binds are clamped to loopback for safety. +# If you want to expose these listeners beyond localhost, you must opt in explicitly. +dangerously_allow_non_loopback_proxy = false +dangerously_allow_non_loopback_admin = false +mode = "limited" # or "full" + +[network_proxy.policy] +# Hosts must match the allowlist (unless denied). +# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. +allowed_domains = ["*.openai.com"] +denied_domains = ["evil.example"] + +# If false, local/private networking is rejected. Explicit allowlisting of local IP literals +# (or `localhost`) is required to permit them. +# Hostnames that resolve to local/private IPs are still blocked even if allowlisted. +allow_local_binding = false + +# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. +allow_unix_sockets = ["/tmp/example.sock"] +``` + +### 2) Run the proxy + +```bash +cargo run -p codex-network-proxy -- +``` + +### 3) Point a client at it + +For HTTP(S) traffic: + +```bash +export HTTP_PROXY="http://127.0.0.1:3128" +export HTTPS_PROXY="http://127.0.0.1:3128" +``` + +### 4) Understand blocks / debugging + +When a request is blocked, the proxy responds with `403` and includes: + +- `x-proxy-error`: one of: + - `blocked-by-allowlist` + - `blocked-by-denylist` + - `blocked-by-method-policy` + - `blocked-by-policy` + +In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed for plain HTTP. HTTPS `CONNECT` +remains a transparent tunnel, so limited-mode method enforcement does not apply to HTTPS. + +## Library API + +`codex-network-proxy` can be embedded as a library with a thin API: + +```rust +use codex_network_proxy::{NetworkProxy, NetworkDecision, NetworkPolicyRequest}; + +let proxy = NetworkProxy::builder() + .http_addr("127.0.0.1:8080".parse()?) + .admin_addr("127.0.0.1:9000".parse()?) + .policy_decider(|request: NetworkPolicyRequest| async move { + // Example: auto-allow when exec policy already approved a command prefix. + if let Some(command) = request.command.as_deref() { + if command.starts_with("curl ") { + return NetworkDecision::Allow; + } + } + NetworkDecision::Deny { + reason: "policy_denied".to_string(), + } + }) + .build() + .await?; + +let handle = proxy.run().await?; +handle.shutdown().await?; +``` + +When unix socket proxying is enabled, HTTP/admin bind overrides are still clamped to loopback +to avoid turning the proxy into a remote bridge to local daemons. + +### Policy hook (exec-policy mapping) + +The proxy exposes a policy hook (`NetworkPolicyDecider`) that can override allowlist-only blocks. +It receives `command` and `exec_policy_hint` fields when supplied by the embedding app. This lets +core map exec approvals to network access, e.g. if a user already approved `curl *` for a session, +the decider can auto-allow network requests originating from that command. + +**Important:** Explicit deny rules still win. The decider only gets a chance to override +`not_allowed` (allowlist misses), not `denied` or `not_allowed_local`. + +## Admin API + +The admin API is a small HTTP server intended for debugging and runtime adjustments. + +Endpoints: + +```bash +curl -sS http://127.0.0.1:8080/health +curl -sS http://127.0.0.1:8080/config +curl -sS http://127.0.0.1:8080/patterns +curl -sS http://127.0.0.1:8080/blocked + +# Switch modes without restarting: +curl -sS -X POST http://127.0.0.1:8080/mode -d '{"mode":"full"}' + +# Force a config reload: +curl -sS -X POST http://127.0.0.1:8080/reload +``` + +## Platform notes + +- Unix socket proxying via the `x-unix-socket` header is **macOS-only**; other platforms will + reject unix socket requests. +- HTTPS tunneling uses BoringSSL via Rama's `rama-tls-boring`; building the proxy requires a + native toolchain and CMake on macOS/Linux/Windows. + +## Security notes (important) + +This section documents the protections implemented by `codex-network-proxy`, and the boundaries of +what it can reasonably guarantee. + +- Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. +- Deny wins: entries in `denied_domains` always override the allowlist. +- Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback + and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) + is required to permit them; hostnames that resolve to local/private IPs are still blocked even if + allowlisted (best-effort DNS lookup). +- Limited mode enforcement: + - only `GET`, `HEAD`, and `OPTIONS` are allowed + - HTTPS `CONNECT` remains a tunnel; limited-mode method enforcement does not apply to HTTPS +- Listener safety defaults: + - the admin API is unauthenticated; non-loopback binds are clamped unless explicitly enabled via + `dangerously_allow_non_loopback_admin` +- the HTTP proxy listener similarly clamps non-loopback binds unless explicitly enabled via + `dangerously_allow_non_loopback_proxy` +- when unix socket proxying is enabled, both listeners are forced to loopback to avoid turning the + proxy into a remote bridge into local daemons. +- `enabled` is enforced at runtime; when false the proxy no-ops and does not bind listeners. +Limitations: + +- DNS rebinding is hard to fully prevent without pinning the resolved IP(s) all the way down to the + transport layer. If your threat model includes hostile DNS, enforce network egress at a lower + layer too (e.g., firewall / VPC / corporate proxy policies). diff --git a/codex-rs/network-proxy/src/admin.rs b/codex-rs/network-proxy/src/admin.rs new file mode 100644 index 0000000000..3c883603a9 --- /dev/null +++ b/codex-rs/network-proxy/src/admin.rs @@ -0,0 +1,160 @@ +use crate::config::NetworkMode; +use crate::responses::json_response; +use crate::responses::text_response; +use crate::state::NetworkProxyState; +use anyhow::Context; +use anyhow::Result; +use rama_core::rt::Executor; +use rama_core::service::service_fn; +use rama_http::Body; +use rama_http::Request; +use rama_http::Response; +use rama_http::StatusCode; +use rama_http_backend::server::HttpServer; +use rama_tcp::server::TcpListener; +use serde::Deserialize; +use serde::Serialize; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::error; +use tracing::info; + +pub async fn run_admin_api(state: Arc, addr: SocketAddr) -> Result<()> { + // Debug-only admin API (health/config/patterns/blocked + mode/reload). Policy is config-driven + // and constraint-enforced; this endpoint should not become a second policy/approval plane. + let listener = TcpListener::build() + .bind(addr) + .await + // See `http_proxy.rs` for details on why we wrap `BoxError` before converting to anyhow. + .map_err(rama_core::error::OpaqueError::from) + .map_err(anyhow::Error::from) + .with_context(|| format!("bind admin API: {addr}"))?; + + let server_state = state.clone(); + let server = HttpServer::auto(Executor::new()).service(service_fn(move |req| { + let state = server_state.clone(); + async move { handle_admin_request(state, req).await } + })); + info!("admin API listening on {addr}"); + listener.serve(server).await; + Ok(()) +} + +async fn handle_admin_request( + state: Arc, + req: Request, +) -> Result { + const MODE_BODY_LIMIT: usize = 8 * 1024; + + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let response = match (method.as_str(), path.as_str()) { + ("GET", "/health") => Response::new(Body::from("ok")), + ("GET", "/config") => match state.current_cfg().await { + Ok(cfg) => json_response(&cfg), + Err(err) => { + error!("failed to load config: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("GET", "/patterns") => match state.current_patterns().await { + Ok((allow, deny)) => json_response(&PatternsResponse { + allowed: allow, + denied: deny, + }), + Err(err) => { + error!("failed to load patterns: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("GET", "/blocked") => match state.drain_blocked().await { + Ok(blocked) => json_response(&BlockedResponse { blocked }), + Err(err) => { + error!("failed to read blocked queue: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("POST", "/mode") => { + let mut body = req.into_body(); + let mut buf: Vec = Vec::new(); + loop { + let chunk = match body.chunk().await { + Ok(chunk) => chunk, + Err(err) => { + error!("failed to read mode body: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "invalid body")); + } + }; + let Some(chunk) = chunk else { + break; + }; + + if buf.len().saturating_add(chunk.len()) > MODE_BODY_LIMIT { + return Ok(text_response( + StatusCode::PAYLOAD_TOO_LARGE, + "body too large", + )); + } + buf.extend_from_slice(&chunk); + } + + if buf.is_empty() { + return Ok(text_response(StatusCode::BAD_REQUEST, "missing body")); + } + let update: ModeUpdate = match serde_json::from_slice(&buf) { + Ok(update) => update, + Err(err) => { + error!("failed to parse mode update: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "invalid json")); + } + }; + match state.set_network_mode(update.mode).await { + Ok(()) => json_response(&ModeUpdateResponse { + status: "ok", + mode: update.mode, + }), + Err(err) => { + error!("mode update failed: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "mode update failed") + } + } + } + ("POST", "/reload") => match state.force_reload().await { + Ok(()) => json_response(&ReloadResponse { status: "reloaded" }), + Err(err) => { + error!("reload failed: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "reload failed") + } + }, + _ => text_response(StatusCode::NOT_FOUND, "not found"), + }; + Ok(response) +} + +#[derive(Deserialize)] +struct ModeUpdate { + mode: NetworkMode, +} + +#[derive(Debug, Serialize)] +struct PatternsResponse { + allowed: Vec, + denied: Vec, +} + +#[derive(Debug, Serialize)] +struct BlockedResponse { + blocked: T, +} + +#[derive(Debug, Serialize)] +struct ModeUpdateResponse { + status: &'static str, + mode: NetworkMode, +} + +#[derive(Debug, Serialize)] +struct ReloadResponse { + status: &'static str, +} diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs new file mode 100644 index 0000000000..7bd6957ac7 --- /dev/null +++ b/codex-rs/network-proxy/src/config.rs @@ -0,0 +1,433 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde::Deserialize; +use serde::Serialize; +use std::net::IpAddr; +use std::net::SocketAddr; +use tracing::warn; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NetworkProxyConfig { + #[serde(default)] + pub network_proxy: NetworkProxySettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkProxySettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_proxy_url")] + pub proxy_url: String, + #[serde(default = "default_admin_url")] + pub admin_url: String, + #[serde(default)] + pub allow_upstream_proxy: bool, + #[serde(default)] + pub dangerously_allow_non_loopback_proxy: bool, + #[serde(default)] + pub dangerously_allow_non_loopback_admin: bool, + #[serde(default)] + pub mode: NetworkMode, + #[serde(default)] + pub policy: NetworkPolicy, +} + +impl Default for NetworkProxySettings { + fn default() -> Self { + Self { + enabled: false, + proxy_url: default_proxy_url(), + admin_url: default_admin_url(), + allow_upstream_proxy: false, + dangerously_allow_non_loopback_proxy: false, + dangerously_allow_non_loopback_admin: false, + mode: NetworkMode::default(), + policy: NetworkPolicy::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NetworkPolicy { + #[serde(default)] + pub allowed_domains: Vec, + #[serde(default)] + pub denied_domains: Vec, + #[serde(default)] + pub allow_unix_sockets: Vec, + #[serde(default)] + pub allow_local_binding: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum NetworkMode { + /// Limited (read-only) access: only GET/HEAD/OPTIONS are allowed for HTTP. HTTPS CONNECT is + /// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests. + Limited, + /// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without + /// MITM interception. + #[default] + Full, +} + +impl NetworkMode { + pub fn allows_method(self, method: &str) -> bool { + match self { + Self::Full => true, + Self::Limited => matches!(method, "GET" | "HEAD" | "OPTIONS"), + } + } +} + +fn default_proxy_url() -> String { + "http://127.0.0.1:3128".to_string() +} + +fn default_admin_url() -> String { + "http://127.0.0.1:8080".to_string() +} + +/// Clamp non-loopback bind addresses to loopback unless explicitly allowed. +fn clamp_non_loopback(addr: SocketAddr, allow_non_loopback: bool, name: &str) -> SocketAddr { + if addr.ip().is_loopback() { + return addr; + } + + if allow_non_loopback { + warn!("DANGEROUS: {name} listening on non-loopback address {addr}"); + return addr; + } + + warn!( + "{name} requested non-loopback bind ({addr}); clamping to 127.0.0.1:{port} (set dangerously_allow_non_loopback_proxy or dangerously_allow_non_loopback_admin to override)", + port = addr.port() + ); + SocketAddr::from(([127, 0, 0, 1], addr.port())) +} + +pub(crate) fn clamp_bind_addrs( + http_addr: SocketAddr, + admin_addr: SocketAddr, + cfg: &NetworkProxySettings, +) -> (SocketAddr, SocketAddr) { + let http_addr = clamp_non_loopback( + http_addr, + cfg.dangerously_allow_non_loopback_proxy, + "HTTP proxy", + ); + let admin_addr = clamp_non_loopback( + admin_addr, + cfg.dangerously_allow_non_loopback_admin, + "admin API", + ); + if cfg.policy.allow_unix_sockets.is_empty() { + return (http_addr, admin_addr); + } + + // `x-unix-socket` is intentionally a local escape hatch. If the proxy (or admin API) is + // reachable from outside the machine, it can become a remote bridge into local daemons + // (e.g. docker.sock). To avoid footguns, enforce loopback binding whenever unix sockets + // are enabled. + if cfg.dangerously_allow_non_loopback_proxy && !http_addr.ip().is_loopback() { + warn!( + "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping HTTP proxy to loopback" + ); + } + if cfg.dangerously_allow_non_loopback_admin && !admin_addr.ip().is_loopback() { + warn!( + "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_admin and clamping admin API to loopback" + ); + } + ( + SocketAddr::from(([127, 0, 0, 1], http_addr.port())), + SocketAddr::from(([127, 0, 0, 1], admin_addr.port())), + ) +} + +pub struct RuntimeConfig { + pub http_addr: SocketAddr, + pub admin_addr: SocketAddr, +} + +pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result { + let http_addr = resolve_addr(&cfg.network_proxy.proxy_url, 3128).with_context(|| { + format!( + "invalid network_proxy.proxy_url: {}", + cfg.network_proxy.proxy_url + ) + })?; + let admin_addr = resolve_addr(&cfg.network_proxy.admin_url, 8080).with_context(|| { + format!( + "invalid network_proxy.admin_url: {}", + cfg.network_proxy.admin_url + ) + })?; + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg.network_proxy); + + Ok(RuntimeConfig { + http_addr, + admin_addr, + }) +} + +fn resolve_addr(url: &str, default_port: u16) -> Result { + let addr_parts = parse_host_port(url, default_port)?; + let host = if addr_parts.host.eq_ignore_ascii_case("localhost") { + "127.0.0.1".to_string() + } else { + addr_parts.host + }; + match host.parse::() { + Ok(ip) => Ok(SocketAddr::new(ip, addr_parts.port)), + Err(_) => Ok(SocketAddr::from(([127, 0, 0, 1], addr_parts.port))), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SocketAddressParts { + host: String, + port: u16, +} + +fn parse_host_port(url: &str, default_port: u16) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + bail!("missing host in network proxy address: {url}"); + } + + // Avoid treating unbracketed IPv6 literals like "2001:db8::1" as scheme-prefixed URLs. + if matches!(trimmed.parse::(), Ok(IpAddr::V6(_))) && !trimmed.starts_with('[') { + return Ok(SocketAddressParts { + host: trimmed.to_string(), + port: default_port, + }); + } + + // Prefer the standard URL parser when the input is URL-like. Prefix a scheme when absent so + // we still accept loose host:port inputs. + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + if let Ok(parsed) = Url::parse(&candidate) + && let Some(host) = parsed.host_str() + { + let host = host.trim_matches(|c| c == '[' || c == ']'); + if host.is_empty() { + bail!("missing host in network proxy address: {url}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port: parsed.port().unwrap_or(default_port), + }); + } + + parse_host_port_fallback(trimmed, default_port) +} + +fn parse_host_port_fallback(input: &str, default_port: u16) -> Result { + let without_scheme = input + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(input); + let host_port = without_scheme.split('/').next().unwrap_or(without_scheme); + let host_port = host_port + .rsplit_once('@') + .map(|(_, rest)| rest) + .unwrap_or(host_port); + + if host_port.starts_with('[') + && let Some(end) = host_port.find(']') + { + let host = &host_port[1..end]; + let port = host_port[end + 1..] + .strip_prefix(':') + .and_then(|port| port.parse::().ok()) + .unwrap_or(default_port); + if host.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port, + }); + } + + // Only treat `host:port` as such when there's a single `:`. This avoids + // accidentally interpreting unbracketed IPv6 addresses as `host:port`. + if host_port.bytes().filter(|b| *b == b':').count() == 1 + && let Some((host, port)) = host_port.rsplit_once(':') + && let Ok(port) = port.parse::() + { + if host.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port, + }); + } + + if host_port.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + Ok(SocketAddressParts { + host: host_port.to_string(), + port: default_port, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn parse_host_port_defaults_for_empty_string() { + assert!(parse_host_port("", 1234).is_err()); + } + + #[test] + fn parse_host_port_defaults_for_whitespace() { + assert!(parse_host_port(" ", 5555).is_err()); + } + + #[test] + fn parse_host_port_parses_host_port_without_scheme() { + assert_eq!( + parse_host_port("127.0.0.1:8080", 3128).unwrap(), + SocketAddressParts { + host: "127.0.0.1".to_string(), + port: 8080, + } + ); + } + + #[test] + fn parse_host_port_parses_host_port_with_scheme_and_path() { + assert_eq!( + parse_host_port("http://example.com:8080/some/path", 3128).unwrap(), + SocketAddressParts { + host: "example.com".to_string(), + port: 8080, + } + ); + } + + #[test] + fn parse_host_port_strips_userinfo() { + assert_eq!( + parse_host_port("http://user:pass@host.example:5555", 3128).unwrap(), + SocketAddressParts { + host: "host.example".to_string(), + port: 5555, + } + ); + } + + #[test] + fn parse_host_port_parses_ipv6_with_brackets() { + assert_eq!( + parse_host_port("http://[::1]:9999", 3128).unwrap(), + SocketAddressParts { + host: "::1".to_string(), + port: 9999, + } + ); + } + + #[test] + fn parse_host_port_does_not_treat_unbracketed_ipv6_as_host_port() { + assert_eq!( + parse_host_port("2001:db8::1", 3128).unwrap(), + SocketAddressParts { + host: "2001:db8::1".to_string(), + port: 3128, + } + ); + } + + #[test] + fn parse_host_port_falls_back_to_default_port_when_port_is_invalid() { + assert_eq!( + parse_host_port("example.com:notaport", 3128).unwrap(), + SocketAddressParts { + host: "example.com:notaport".to_string(), + port: 3128, + } + ); + } + + #[test] + fn resolve_addr_maps_localhost_to_loopback() { + assert_eq!( + resolve_addr("localhost", 3128).unwrap(), + "127.0.0.1:3128".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_parses_ip_literals() { + assert_eq!( + resolve_addr("1.2.3.4", 80).unwrap(), + "1.2.3.4:80".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_parses_ipv6_literals() { + assert_eq!( + resolve_addr("http://[::1]:8080", 3128).unwrap(), + "[::1]:8080".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_falls_back_to_loopback_for_hostnames() { + assert_eq!( + resolve_addr("http://example.com:5555", 3128).unwrap(), + "127.0.0.1:5555".parse::().unwrap() + ); + } + + #[test] + fn clamp_bind_addrs_allows_non_loopback_when_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "0.0.0.0:3128".parse::().unwrap()); + assert_eq!(admin_addr, "0.0.0.0:8080".parse::().unwrap()); + } + + #[test] + fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + policy: NetworkPolicy { + allow_unix_sockets: vec!["/tmp/docker.sock".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "127.0.0.1:3128".parse::().unwrap()); + assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); + } +} diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs new file mode 100644 index 0000000000..abe6b30144 --- /dev/null +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -0,0 +1,636 @@ +use crate::config::NetworkMode; +use crate::network_policy::NetworkDecision; +use crate::network_policy::NetworkPolicyDecider; +use crate::network_policy::NetworkPolicyRequest; +use crate::network_policy::NetworkProtocol; +use crate::network_policy::evaluate_host_policy; +use crate::policy::normalize_host; +use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_PROXY_DISABLED; +use crate::responses::blocked_header_value; +use crate::responses::json_response; +use crate::runtime::unix_socket_permissions_supported; +use crate::state::BlockedRequest; +use crate::state::NetworkProxyState; +use crate::upstream::UpstreamClient; +use crate::upstream::proxy_for_connect; +use anyhow::Context as _; +use anyhow::Result; +use rama_core::Layer; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::error::ErrorExt as _; +use rama_core::error::OpaqueError; +use rama_core::extensions::ExtensionsMut; +use rama_core::extensions::ExtensionsRef; +use rama_core::layer::AddInputExtensionLayer; +use rama_core::rt::Executor; +use rama_core::service::service_fn; +use rama_http::Body; +use rama_http::HeaderValue; +use rama_http::Request; +use rama_http::Response; +use rama_http::StatusCode; +use rama_http::layer::remove_header::RemoveRequestHeaderLayer; +use rama_http::layer::remove_header::RemoveResponseHeaderLayer; +use rama_http::matcher::MethodMatcher; +use rama_http_backend::client::proxy::layer::HttpProxyConnector; +use rama_http_backend::server::HttpServer; +use rama_http_backend::server::layer::upgrade::UpgradeLayer; +use rama_http_backend::server::layer::upgrade::Upgraded; +use rama_net::Protocol; +use rama_net::address::ProxyAddress; +use rama_net::client::ConnectorService; +use rama_net::client::EstablishedClientConnection; +use rama_net::http::RequestContext; +use rama_net::proxy::ProxyRequest; +use rama_net::proxy::ProxyTarget; +use rama_net::proxy::StreamForwardService; +use rama_net::stream::SocketInfo; +use rama_tcp::client::Request as TcpRequest; +use rama_tcp::client::service::TcpConnector; +use rama_tcp::server::TcpListener; +use rama_tls_boring::client::TlsConnectorDataBuilder; +use rama_tls_boring::client::TlsConnectorLayer; +use serde::Serialize; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::error; +use tracing::info; +use tracing::warn; + +pub async fn run_http_proxy( + state: Arc, + addr: SocketAddr, + policy_decider: Option>, +) -> Result<()> { + let listener = TcpListener::build() + .bind(addr) + .await + // Rama's `BoxError` is a `Box` without an explicit `'static` + // lifetime bound, which means it doesn't satisfy `anyhow::Context`'s `StdError` constraint. + // Wrap it in Rama's `OpaqueError` so we can preserve the original error as a source and + // still use `anyhow` for chaining. + .map_err(rama_core::error::OpaqueError::from) + .map_err(anyhow::Error::from) + .with_context(|| format!("bind HTTP proxy: {addr}"))?; + + let http_service = HttpServer::auto(Executor::new()).service( + ( + UpgradeLayer::new( + MethodMatcher::CONNECT, + service_fn({ + let policy_decider = policy_decider.clone(); + move |req| http_connect_accept(policy_decider.clone(), req) + }), + service_fn(http_connect_proxy), + ), + RemoveResponseHeaderLayer::hop_by_hop(), + RemoveRequestHeaderLayer::hop_by_hop(), + ) + .into_layer(service_fn({ + let policy_decider = policy_decider.clone(); + move |req| http_plain_proxy(policy_decider.clone(), req) + })), + ); + + info!("HTTP proxy listening on {addr}"); + + listener + .serve(AddInputExtensionLayer::new(state).into_layer(http_service)) + .await; + Ok(()) +} + +async fn http_connect_accept( + policy_decider: Option>, + mut req: Request, +) -> Result<(Response, Request), Response> { + let app_state = req + .extensions() + .get::>() + .cloned() + .ok_or_else(|| text_response(StatusCode::INTERNAL_SERVER_ERROR, "missing state"))?; + + let authority = match RequestContext::try_from(&req).map(|ctx| ctx.host_with_port()) { + Ok(authority) => authority, + Err(err) => { + warn!("CONNECT missing authority: {err}"); + return Err(text_response(StatusCode::BAD_REQUEST, "missing authority")); + } + }; + + let host = normalize_host(&authority.host.to_string()); + if host.is_empty() { + return Err(text_response(StatusCode::BAD_REQUEST, "invalid host")); + } + + let client = client_addr(&req); + + let enabled = app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err))?; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked; proxy disabled (client={client}, host={host})"); + return Err(proxy_disabled_response( + &app_state, + host, + client_addr(&req), + Some("CONNECT".to_string()), + "http-connect", + ) + .await); + } + + let request = NetworkPolicyRequest::new( + NetworkProtocol::HttpsConnect, + host.clone(), + authority.port, + client.clone(), + Some("CONNECT".to_string()), + None, + None, + ); + + match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { + Ok(NetworkDecision::Deny { reason }) => { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + reason.clone(), + client.clone(), + Some("CONNECT".to_string()), + None, + "http-connect".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked (client={client}, host={host}, reason={reason})"); + return Err(blocked_text(&reason)); + } + Ok(NetworkDecision::Allow) => { + let client = client.as_deref().unwrap_or_default(); + info!("CONNECT allowed (client={client}, host={host})"); + } + Err(err) => { + error!("failed to evaluate host for CONNECT {host}: {err}"); + return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + } + + let mode = app_state + .network_mode() + .await + .map_err(|err| internal_error("failed to read network mode", err))?; + + if mode == NetworkMode::Limited { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + REASON_METHOD_NOT_ALLOWED.to_string(), + client.clone(), + Some("CONNECT".to_string()), + Some(NetworkMode::Limited), + "http-connect".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked by method policy (client={client}, host={host}, mode=limited)"); + return Err(blocked_text(REASON_METHOD_NOT_ALLOWED)); + } + + req.extensions_mut().insert(ProxyTarget(authority)); + req.extensions_mut().insert(mode); + + Ok(( + Response::builder() + .status(StatusCode::OK) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())), + req, + )) +} + +async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> { + if upgraded.extensions().get::().is_none() { + warn!("CONNECT missing proxy target"); + return Ok(()); + } + + let allow_upstream_proxy = match upgraded + .extensions() + .get::>() + .cloned() + { + Some(state) => match state.allow_upstream_proxy().await { + Ok(allowed) => allowed, + Err(err) => { + error!("failed to read upstream proxy setting: {err}"); + false + } + }, + None => { + error!("missing app state"); + false + } + }; + + let proxy = if allow_upstream_proxy { + proxy_for_connect() + } else { + None + }; + + if let Err(err) = forward_connect_tunnel(upgraded, proxy).await { + warn!("tunnel error: {err}"); + } + Ok(()) +} + +async fn forward_connect_tunnel( + upgraded: Upgraded, + proxy: Option, +) -> Result<(), BoxError> { + let authority = upgraded + .extensions() + .get::() + .map(|target| target.0.clone()) + .ok_or_else(|| OpaqueError::from_display("missing forward authority").into_boxed())?; + + let mut extensions = upgraded.extensions().clone(); + if let Some(proxy) = proxy { + extensions.insert(proxy); + } + + let req = TcpRequest::new_with_extensions(authority.clone(), extensions) + .with_protocol(Protocol::HTTPS); + let proxy_connector = HttpProxyConnector::optional(TcpConnector::new()); + let tls_config = TlsConnectorDataBuilder::new_http_auto().into_shared_builder(); + let connector = TlsConnectorLayer::tunnel(None) + .with_connector_data(tls_config) + .into_layer(proxy_connector); + let EstablishedClientConnection { conn: target, .. } = + connector.connect(req).await.map_err(|err| { + OpaqueError::from_boxed(err) + .with_context(|| format!("establish CONNECT tunnel to {authority}")) + .into_boxed() + })?; + + let proxy_req = ProxyRequest { + source: upgraded, + target, + }; + StreamForwardService::default() + .serve(proxy_req) + .await + .map_err(|err| { + OpaqueError::from_boxed(err.into()) + .with_context(|| format!("forward CONNECT tunnel to {authority}")) + .into_boxed() + }) +} + +async fn http_plain_proxy( + policy_decider: Option>, + req: Request, +) -> Result { + let app_state = match req.extensions().get::>().cloned() { + Some(state) => state, + None => { + error!("missing app state"); + return Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + }; + let client = client_addr(&req); + + let method_allowed = match app_state + .method_allowed(req.method().as_str()) + .await + .map_err(|err| internal_error("failed to evaluate method policy", err)) + { + Ok(allowed) => allowed, + Err(resp) => return Ok(resp), + }; + + // `x-unix-socket` is an escape hatch for talking to local daemons. We keep it tightly scoped: + // macOS-only + explicit allowlist, to avoid turning the proxy into a general local capability + // escalation mechanism. + if let Some(unix_socket_header) = req.headers().get("x-unix-socket") { + let socket_path = match unix_socket_header.to_str() { + Ok(value) => value.to_string(), + Err(_) => { + warn!("invalid x-unix-socket header value (non-UTF8)"); + return Ok(text_response( + StatusCode::BAD_REQUEST, + "invalid x-unix-socket header", + )); + } + }; + let enabled = match app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err)) + { + Ok(enabled) => enabled, + Err(resp) => return Ok(resp), + }; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + warn!("unix socket blocked; proxy disabled (client={client}, path={socket_path})"); + return Ok(proxy_disabled_response( + &app_state, + socket_path, + client_addr(&req), + Some(req.method().as_str().to_string()), + "unix-socket", + ) + .await); + } + if !method_allowed { + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!( + "unix socket blocked by method policy (client={client}, method={method}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + ); + return Ok(json_blocked("unix-socket", REASON_METHOD_NOT_ALLOWED)); + } + + if !unix_socket_permissions_supported() { + warn!("unix socket proxy unsupported on this platform (path={socket_path})"); + return Ok(text_response( + StatusCode::NOT_IMPLEMENTED, + "unix sockets unsupported", + )); + } + + return match app_state.is_unix_socket_allowed(&socket_path).await { + Ok(true) => { + let client = client.as_deref().unwrap_or_default(); + info!("unix socket allowed (client={client}, path={socket_path})"); + match proxy_via_unix_socket(req, &socket_path).await { + Ok(resp) => Ok(resp), + Err(err) => { + warn!("unix socket proxy failed: {err}"); + Ok(text_response( + StatusCode::BAD_GATEWAY, + "unix socket proxy failed", + )) + } + } + } + Ok(false) => { + let client = client.as_deref().unwrap_or_default(); + warn!("unix socket blocked (client={client}, path={socket_path})"); + Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED)) + } + Err(err) => { + warn!("unix socket check failed: {err}"); + Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")) + } + }; + } + + let authority = match RequestContext::try_from(&req).map(|ctx| ctx.host_with_port()) { + Ok(authority) => authority, + Err(err) => { + warn!("missing host: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "missing host")); + } + }; + let host = normalize_host(&authority.host.to_string()); + let port = authority.port; + let enabled = match app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err)) + { + Ok(enabled) => enabled, + Err(resp) => return Ok(resp), + }; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!("request blocked; proxy disabled (client={client}, host={host}, method={method})"); + return Ok(proxy_disabled_response( + &app_state, + host, + client_addr(&req), + Some(req.method().as_str().to_string()), + "http", + ) + .await); + } + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + host.clone(), + port, + client.clone(), + Some(req.method().as_str().to_string()), + None, + None, + ); + + match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { + Ok(NetworkDecision::Deny { reason }) => { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + reason.clone(), + client.clone(), + Some(req.method().as_str().to_string()), + None, + "http".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("request blocked (client={client}, host={host}, reason={reason})"); + return Ok(json_blocked(&host, &reason)); + } + Ok(NetworkDecision::Allow) => {} + Err(err) => { + error!("failed to evaluate host for {host}: {err}"); + return Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + } + + if !method_allowed { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + REASON_METHOD_NOT_ALLOWED.to_string(), + client.clone(), + Some(req.method().as_str().to_string()), + Some(NetworkMode::Limited), + "http".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!( + "request blocked by method policy (client={client}, host={host}, method={method}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + ); + return Ok(json_blocked(&host, REASON_METHOD_NOT_ALLOWED)); + } + + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + info!("request allowed (client={client}, host={host}, method={method})"); + + let allow_upstream_proxy = match app_state + .allow_upstream_proxy() + .await + .map_err(|err| internal_error("failed to read upstream proxy config", err)) + { + Ok(allow) => allow, + Err(resp) => return Ok(resp), + }; + let client = if allow_upstream_proxy { + UpstreamClient::from_env_proxy() + } else { + UpstreamClient::direct() + }; + + match client.serve(req).await { + Ok(resp) => Ok(resp), + Err(err) => { + warn!("upstream request failed: {err}"); + Ok(text_response(StatusCode::BAD_GATEWAY, "upstream failure")) + } + } +} + +async fn proxy_via_unix_socket(req: Request, socket_path: &str) -> Result { + #[cfg(target_os = "macos")] + { + let client = UpstreamClient::unix_socket(socket_path); + + let (mut parts, body) = req.into_parts(); + let path = parts + .uri + .path_and_query() + .map(rama_http::uri::PathAndQuery::as_str) + .unwrap_or("/"); + parts.uri = path + .parse() + .with_context(|| format!("invalid unix socket request path: {path}"))?; + parts.headers.remove("x-unix-socket"); + + let req = Request::from_parts(parts, body); + client.serve(req).await.map_err(anyhow::Error::from) + } + #[cfg(not(target_os = "macos"))] + { + let _ = req; + let _ = socket_path; + Err(anyhow::anyhow!("unix sockets not supported")) + } +} + +fn client_addr(input: &T) -> Option { + input + .extensions() + .get::() + .map(|info| info.peer_addr().to_string()) +} + +fn json_blocked(host: &str, reason: &str) -> Response { + let response = BlockedResponse { + status: "blocked", + host, + reason, + }; + let mut resp = json_response(&response); + *resp.status_mut() = StatusCode::FORBIDDEN; + resp.headers_mut().insert( + "x-proxy-error", + HeaderValue::from_static(blocked_header_value(reason)), + ); + resp +} + +fn blocked_text(reason: &str) -> Response { + crate::responses::blocked_text_response(reason) +} + +async fn proxy_disabled_response( + app_state: &NetworkProxyState, + host: String, + client: Option, + method: Option, + protocol: &str, +) -> Response { + let _ = app_state + .record_blocked(BlockedRequest::new( + host, + REASON_PROXY_DISABLED.to_string(), + client, + method, + None, + protocol.to_string(), + )) + .await; + text_response(StatusCode::SERVICE_UNAVAILABLE, "proxy disabled") +} + +fn internal_error(context: &str, err: impl std::fmt::Display) -> Response { + error!("{context}: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") +} + +fn text_response(status: StatusCode, body: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(Body::from(body.to_string())) + .unwrap_or_else(|_| Response::new(Body::from(body.to_string()))) +} + +#[derive(Serialize)] +struct BlockedResponse<'a> { + status: &'static str, + host: &'a str, + reason: &'a str, +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkMode; + use crate::config::NetworkPolicy; + use crate::runtime::network_proxy_state_for_policy; + use pretty_assertions::assert_eq; + use rama_http::Method; + use rama_http::Request; + use std::sync::Arc; + + #[tokio::test] + async fn http_connect_accept_blocks_in_limited_mode() { + let policy = NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + ..Default::default() + }; + let state = Arc::new(network_proxy_state_for_policy(policy)); + state.set_network_mode(NetworkMode::Limited).await.unwrap(); + + let mut req = Request::builder() + .method(Method::CONNECT) + .uri("https://example.com:443") + .header("host", "example.com:443") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(state); + + let response = http_connect_accept(None, req).await.unwrap_err(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-method-policy" + ); + } +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs new file mode 100644 index 0000000000..71e4ec7356 --- /dev/null +++ b/codex-rs/network-proxy/src/lib.rs @@ -0,0 +1,29 @@ +#![deny(clippy::print_stdout, clippy::print_stderr)] + +mod admin; +mod config; +mod http_proxy; +mod network_policy; +mod policy; +mod proxy; +mod reasons; +mod responses; +mod runtime; +mod state; +mod upstream; + +use anyhow::Result; +pub use network_policy::NetworkDecision; +pub use network_policy::NetworkPolicyDecider; +pub use network_policy::NetworkPolicyRequest; +pub use network_policy::NetworkProtocol; +pub use proxy::Args; +pub use proxy::NetworkProxy; +pub use proxy::NetworkProxyBuilder; +pub use proxy::NetworkProxyHandle; + +pub async fn run_main(args: Args) -> Result<()> { + let _ = args; + let proxy = NetworkProxy::builder().build().await?; + proxy.run().await?.wait().await +} diff --git a/codex-rs/network-proxy/src/main.rs b/codex-rs/network-proxy/src/main.rs new file mode 100644 index 0000000000..7cb28aad57 --- /dev/null +++ b/codex-rs/network-proxy/src/main.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use clap::Parser; +use codex_network_proxy::Args; +use codex_network_proxy::NetworkProxy; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let _ = args; + let proxy = NetworkProxy::builder().build().await?; + proxy.run().await?.wait().await +} diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs new file mode 100644 index 0000000000..6f410b3e4b --- /dev/null +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -0,0 +1,234 @@ +use crate::reasons::REASON_POLICY_DENIED; +use crate::runtime::HostBlockDecision; +use crate::runtime::HostBlockReason; +use crate::state::NetworkProxyState; +use anyhow::Result; +use async_trait::async_trait; +use std::future::Future; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NetworkProtocol { + Http, + HttpsConnect, + Socks5Tcp, + Socks5Udp, +} + +#[derive(Clone, Debug)] +pub struct NetworkPolicyRequest { + pub protocol: NetworkProtocol, + pub host: String, + pub port: u16, + pub client_addr: Option, + pub method: Option, + pub command: Option, + pub exec_policy_hint: Option, +} + +impl NetworkPolicyRequest { + pub fn new( + protocol: NetworkProtocol, + host: String, + port: u16, + client_addr: Option, + method: Option, + command: Option, + exec_policy_hint: Option, + ) -> Self { + Self { + protocol, + host, + port, + client_addr, + method, + command, + exec_policy_hint, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NetworkDecision { + Allow, + Deny { reason: String }, +} + +impl NetworkDecision { + pub fn deny(reason: impl Into) -> Self { + let reason = reason.into(); + let reason = if reason.is_empty() { + REASON_POLICY_DENIED.to_string() + } else { + reason + }; + Self::Deny { reason } + } +} + +/// Decide whether a network request should be allowed. +/// +/// If `command` or `exec_policy_hint` is provided, callers can map exec-policy +/// approvals to network access (e.g., allow all requests for commands matching +/// approved prefixes like `curl *`). +#[async_trait] +pub trait NetworkPolicyDecider: Send + Sync + 'static { + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision; +} + +#[async_trait] +impl NetworkPolicyDecider for Arc { + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision { + (**self).decide(req).await + } +} + +#[async_trait] +impl NetworkPolicyDecider for F +where + F: Fn(NetworkPolicyRequest) -> Fut + Send + Sync + 'static, + Fut: Future + Send, +{ + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision { + (self)(req).await + } +} + +pub(crate) async fn evaluate_host_policy( + state: &NetworkProxyState, + decider: Option<&Arc>, + request: &NetworkPolicyRequest, +) -> Result { + match state.host_blocked(&request.host, request.port).await? { + HostBlockDecision::Allowed => Ok(NetworkDecision::Allow), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => { + if let Some(decider) = decider { + Ok(decider.decide(request.clone()).await) + } else { + Ok(NetworkDecision::deny(HostBlockReason::NotAllowed.as_str())) + } + } + HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny(reason.as_str())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkPolicy; + use crate::reasons::REASON_DENIED; + use crate::reasons::REASON_NOT_ALLOWED_LOCAL; + use crate::state::network_proxy_state_for_policy; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + #[tokio::test] + async fn evaluate_host_policy_invokes_decider_for_not_allowed() { + let state = network_proxy_state_for_policy(NetworkPolicy::default()); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + // The default policy denies all; the decider is consulted for not_allowed + // requests and can override that decision. + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "example.com".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!(decision, NetworkDecision::Allow); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn evaluate_host_policy_skips_decider_for_denied() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["blocked.com".to_string()], + ..NetworkPolicy::default() + }); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "blocked.com".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!( + decision, + NetworkDecision::Deny { + reason: REASON_DENIED.to_string() + } + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn evaluate_host_policy_skips_decider_for_not_allowed_local() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "127.0.0.1".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!( + decision, + NetworkDecision::Deny { + reason: REASON_NOT_ALLOWED_LOCAL.to_string() + } + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } +} diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs new file mode 100644 index 0000000000..d663aea065 --- /dev/null +++ b/codex-rs/network-proxy/src/policy.rs @@ -0,0 +1,435 @@ +#[cfg(test)] +use crate::config::NetworkMode; +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use globset::GlobBuilder; +use globset::GlobSet; +use globset::GlobSetBuilder; +use std::collections::HashSet; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use url::Host as UrlHost; + +/// A normalized host string for policy evaluation. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Host(String); + +impl Host { + pub fn parse(input: &str) -> Result { + let normalized = normalize_host(input); + ensure!(!normalized.is_empty(), "host is empty"); + Ok(Self(normalized)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Returns true if the host is a loopback hostname or IP literal. +pub fn is_loopback_host(host: &Host) -> bool { + let host = host.as_str(); + let host = host.split_once('%').map(|(ip, _)| ip).unwrap_or(host); + if host == "localhost" { + return true; + } + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + false +} + +pub fn is_non_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_non_public_ipv4(ip), + IpAddr::V6(ip) => is_non_public_ipv6(ip), + } +} + +fn is_non_public_ipv4(ip: Ipv4Addr) -> bool { + // Use the standard library classification helpers where possible; they encode the intent more + // clearly than hand-rolled range checks. Some non-public ranges (e.g., CGNAT and TEST-NET + // blocks) are not covered by stable stdlib helpers yet, so we fall back to CIDR checks. + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_broadcast() + || ipv4_in_cidr(ip, [0, 0, 0, 0], 8) // "this network" (RFC 1122) + || ipv4_in_cidr(ip, [100, 64, 0, 0], 10) // CGNAT (RFC 6598) + || ipv4_in_cidr(ip, [192, 0, 0, 0], 24) // IETF Protocol Assignments (RFC 6890) + || ipv4_in_cidr(ip, [192, 0, 2, 0], 24) // TEST-NET-1 (RFC 5737) + || ipv4_in_cidr(ip, [198, 18, 0, 0], 15) // Benchmarking (RFC 2544) + || ipv4_in_cidr(ip, [198, 51, 100, 0], 24) // TEST-NET-2 (RFC 5737) + || ipv4_in_cidr(ip, [203, 0, 113, 0], 24) // TEST-NET-3 (RFC 5737) + || ipv4_in_cidr(ip, [240, 0, 0, 0], 4) // Reserved (RFC 6890) +} + +fn ipv4_in_cidr(ip: Ipv4Addr, base: [u8; 4], prefix: u8) -> bool { + let ip = u32::from(ip); + let base = u32::from(Ipv4Addr::from(base)); + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + (ip & mask) == (base & mask) +} + +fn is_non_public_ipv6(ip: Ipv6Addr) -> bool { + if let Some(v4) = ip.to_ipv4() { + return is_non_public_ipv4(v4) || ip.is_loopback(); + } + // Treat anything that isn't globally routable as "local" for SSRF prevention. In particular: + // - `::1` loopback + // - `fc00::/7` unique-local (RFC 4193) + // - `fe80::/10` link-local + // - `::` unspecified + // - multicast ranges + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() +} + +/// Normalize host fragments for policy matching (trim whitespace, strip ports/brackets, lowercase). +pub fn normalize_host(host: &str) -> String { + let host = host.trim(); + if host.starts_with('[') + && let Some(end) = host.find(']') + { + return normalize_dns_host(&host[1..end]); + } + + // The proxy stack should typically hand us a host without a port, but be + // defensive and strip `:port` when there is exactly one `:`. + if host.bytes().filter(|b| *b == b':').count() == 1 { + let host = host.split(':').next().unwrap_or_default(); + return normalize_dns_host(host); + } + + // Avoid mangling unbracketed IPv6 literals, but strip trailing dots so fully qualified domain + // names are treated the same as their dotless variants. + normalize_dns_host(host) +} + +fn normalize_dns_host(host: &str) -> String { + let host = host.to_ascii_lowercase(); + host.trim_end_matches('.').to_string() +} + +fn normalize_pattern(pattern: &str) -> String { + let pattern = pattern.trim(); + if pattern == "*" { + return "*".to_string(); + } + + let (prefix, remainder) = if let Some(domain) = pattern.strip_prefix("**.") { + ("**.", domain) + } else if let Some(domain) = pattern.strip_prefix("*.") { + ("*.", domain) + } else { + ("", pattern) + }; + + let remainder = normalize_host(remainder); + if prefix.is_empty() { + remainder + } else { + format!("{prefix}{remainder}") + } +} + +pub(crate) fn compile_globset(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + let mut seen = HashSet::new(); + for pattern in patterns { + let pattern = normalize_pattern(pattern); + // Supported domain patterns: + // - "example.com": match the exact host + // - "*.example.com": match any subdomain (not the apex) + // - "**.example.com": match the apex and any subdomain + // - "*": match any host + for candidate in expand_domain_pattern(&pattern) { + if !seen.insert(candidate.clone()) { + continue; + } + let glob = GlobBuilder::new(&candidate) + .case_insensitive(true) + .build() + .with_context(|| format!("invalid glob pattern: {candidate}"))?; + builder.add(glob); + } + } + Ok(builder.build()?) +} + +#[derive(Debug, Clone)] +pub(crate) enum DomainPattern { + Any, + ApexAndSubdomains(String), + SubdomainsOnly(String), + Exact(String), +} + +impl DomainPattern { + /// Parse a policy pattern for constraint comparisons. + /// + /// Validation of glob syntax happens when building the globset; here we only + /// decode the wildcard prefixes to keep constraint checks lightweight. + pub(crate) fn parse(input: &str) -> Self { + let input = input.trim(); + if input.is_empty() { + return Self::Exact(String::new()); + } + if input == "*" { + Self::Any + } else if let Some(domain) = input.strip_prefix("**.") { + Self::parse_domain(domain, Self::ApexAndSubdomains) + } else if let Some(domain) = input.strip_prefix("*.") { + Self::parse_domain(domain, Self::SubdomainsOnly) + } else { + Self::Exact(input.to_string()) + } + } + + /// Parse a policy pattern for constraint comparisons, validating domain parts with `url`. + pub(crate) fn parse_for_constraints(input: &str) -> Self { + let input = input.trim(); + if input.is_empty() { + return Self::Exact(String::new()); + } + if input == "*" { + return Self::Any; + } + if let Some(domain) = input.strip_prefix("**.") { + return Self::ApexAndSubdomains(parse_domain_for_constraints(domain)); + } + if let Some(domain) = input.strip_prefix("*.") { + return Self::SubdomainsOnly(parse_domain_for_constraints(domain)); + } + Self::Exact(parse_domain_for_constraints(input)) + } + + fn parse_domain(domain: &str, build: impl FnOnce(String) -> Self) -> Self { + let domain = domain.trim(); + if domain.is_empty() { + return Self::Exact(String::new()); + } + build(domain.to_string()) + } + + pub(crate) fn allows(&self, candidate: &DomainPattern) -> bool { + match self { + DomainPattern::Any => true, + DomainPattern::Exact(domain) => match candidate { + DomainPattern::Exact(candidate) => domain_eq(candidate, domain), + _ => false, + }, + DomainPattern::SubdomainsOnly(domain) => match candidate { + DomainPattern::Any => false, + DomainPattern::Exact(candidate) => is_strict_subdomain(candidate, domain), + DomainPattern::SubdomainsOnly(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + DomainPattern::ApexAndSubdomains(candidate) => { + is_strict_subdomain(candidate, domain) + } + }, + DomainPattern::ApexAndSubdomains(domain) => match candidate { + DomainPattern::Any => false, + DomainPattern::Exact(candidate) => is_subdomain_or_equal(candidate, domain), + DomainPattern::SubdomainsOnly(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + DomainPattern::ApexAndSubdomains(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + }, + } + } +} + +fn parse_domain_for_constraints(domain: &str) -> String { + let domain = domain.trim().trim_end_matches('.'); + if domain.is_empty() { + return String::new(); + } + let host = if domain.starts_with('[') && domain.ends_with(']') { + &domain[1..domain.len().saturating_sub(1)] + } else { + domain + }; + if host.contains('*') || host.contains('?') || host.contains('%') { + return domain.to_string(); + } + match UrlHost::parse(host) { + Ok(host) => host.to_string(), + Err(_) => String::new(), + } +} + +fn expand_domain_pattern(pattern: &str) -> Vec { + match DomainPattern::parse(pattern) { + DomainPattern::Any => vec![pattern.to_string()], + DomainPattern::Exact(domain) => vec![domain], + DomainPattern::SubdomainsOnly(domain) => { + vec![format!("?*.{domain}")] + } + DomainPattern::ApexAndSubdomains(domain) => { + vec![domain.clone(), format!("?*.{domain}")] + } + } +} + +fn normalize_domain(domain: &str) -> String { + domain.trim_end_matches('.').to_ascii_lowercase() +} + +fn domain_eq(left: &str, right: &str) -> bool { + normalize_domain(left) == normalize_domain(right) +} + +fn is_subdomain_or_equal(child: &str, parent: &str) -> bool { + let child = normalize_domain(child); + let parent = normalize_domain(parent); + if child == parent { + return true; + } + child.ends_with(&format!(".{parent}")) +} + +fn is_strict_subdomain(child: &str, parent: &str) -> bool { + let child = normalize_domain(child); + let parent = normalize_domain(parent); + child != parent && child.ends_with(&format!(".{parent}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn method_allowed_full_allows_everything() { + assert!(NetworkMode::Full.allows_method("GET")); + assert!(NetworkMode::Full.allows_method("POST")); + assert!(NetworkMode::Full.allows_method("CONNECT")); + } + + #[test] + fn method_allowed_limited_allows_only_safe_methods() { + assert!(NetworkMode::Limited.allows_method("GET")); + assert!(NetworkMode::Limited.allows_method("HEAD")); + assert!(NetworkMode::Limited.allows_method("OPTIONS")); + assert!(!NetworkMode::Limited.allows_method("POST")); + assert!(!NetworkMode::Limited.allows_method("CONNECT")); + } + + #[test] + fn compile_globset_normalizes_trailing_dots() { + let set = compile_globset(&["Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("example.com")); + assert_eq!(false, set.is_match("api.example.com")); + } + + #[test] + fn compile_globset_normalizes_wildcards() { + let set = compile_globset(&["*.Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("api.example.com")); + assert_eq!(false, set.is_match("example.com")); + } + + #[test] + fn compile_globset_normalizes_apex_and_subdomains() { + let set = compile_globset(&["**.Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("example.com")); + assert_eq!(true, set.is_match("api.example.com")); + } + + #[test] + fn compile_globset_normalizes_bracketed_ipv6_literals() { + let set = compile_globset(&["[::1]".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("::1")); + } + + #[test] + fn is_loopback_host_handles_localhost_variants() { + assert!(is_loopback_host(&Host::parse("localhost").unwrap())); + assert!(is_loopback_host(&Host::parse("localhost.").unwrap())); + assert!(is_loopback_host(&Host::parse("LOCALHOST").unwrap())); + assert!(!is_loopback_host(&Host::parse("notlocalhost").unwrap())); + } + + #[test] + fn is_loopback_host_handles_ip_literals() { + assert!(is_loopback_host(&Host::parse("127.0.0.1").unwrap())); + assert!(is_loopback_host(&Host::parse("::1").unwrap())); + assert!(!is_loopback_host(&Host::parse("1.2.3.4").unwrap())); + } + + #[test] + fn is_non_public_ip_rejects_private_and_loopback_ranges() { + assert!(is_non_public_ip("127.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("10.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.168.0.1".parse().unwrap())); + assert!(is_non_public_ip("100.64.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.0.2.1".parse().unwrap())); + assert!(is_non_public_ip("198.18.0.1".parse().unwrap())); + assert!(is_non_public_ip("198.51.100.1".parse().unwrap())); + assert!(is_non_public_ip("203.0.113.1".parse().unwrap())); + assert!(is_non_public_ip("240.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("0.1.2.3".parse().unwrap())); + assert!(!is_non_public_ip("8.8.8.8".parse().unwrap())); + + assert!(is_non_public_ip("::ffff:127.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("::ffff:10.0.0.1".parse().unwrap())); + assert!(!is_non_public_ip("::ffff:8.8.8.8".parse().unwrap())); + + assert!(is_non_public_ip("::1".parse().unwrap())); + assert!(is_non_public_ip("fe80::1".parse().unwrap())); + assert!(is_non_public_ip("fc00::1".parse().unwrap())); + } + + #[test] + fn normalize_host_lowercases_and_trims() { + assert_eq!(normalize_host(" ExAmPlE.CoM "), "example.com"); + } + + #[test] + fn normalize_host_strips_port_for_host_port() { + assert_eq!(normalize_host("example.com:1234"), "example.com"); + } + + #[test] + fn normalize_host_preserves_unbracketed_ipv6() { + assert_eq!(normalize_host("2001:db8::1"), "2001:db8::1"); + } + + #[test] + fn normalize_host_strips_trailing_dot() { + assert_eq!(normalize_host("example.com."), "example.com"); + assert_eq!(normalize_host("ExAmPlE.CoM."), "example.com"); + } + + #[test] + fn normalize_host_strips_trailing_dot_with_port() { + assert_eq!(normalize_host("example.com.:443"), "example.com"); + } + + #[test] + fn normalize_host_strips_brackets_for_ipv6() { + assert_eq!(normalize_host("[::1]"), "::1"); + assert_eq!(normalize_host("[::1]:443"), "::1"); + } +} diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs new file mode 100644 index 0000000000..480880e946 --- /dev/null +++ b/codex-rs/network-proxy/src/proxy.rs @@ -0,0 +1,176 @@ +use crate::admin; +use crate::config; +use crate::http_proxy; +use crate::network_policy::NetworkPolicyDecider; +use crate::runtime::unix_socket_permissions_supported; +use crate::state::NetworkProxyState; +use anyhow::Context; +use anyhow::Result; +use clap::Parser; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::task::JoinHandle; +use tracing::warn; + +#[derive(Debug, Clone, Parser)] +#[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")] +pub struct Args {} + +#[derive(Clone, Default)] +pub struct NetworkProxyBuilder { + state: Option>, + http_addr: Option, + admin_addr: Option, + policy_decider: Option>, +} + +impl NetworkProxyBuilder { + pub fn state(mut self, state: Arc) -> Self { + self.state = Some(state); + self + } + + pub fn http_addr(mut self, addr: SocketAddr) -> Self { + self.http_addr = Some(addr); + self + } + + pub fn admin_addr(mut self, addr: SocketAddr) -> Self { + self.admin_addr = Some(addr); + self + } + + pub fn policy_decider(mut self, decider: D) -> Self + where + D: NetworkPolicyDecider, + { + self.policy_decider = Some(Arc::new(decider)); + self + } + + pub fn policy_decider_arc(mut self, decider: Arc) -> Self { + self.policy_decider = Some(decider); + self + } + + pub async fn build(self) -> Result { + let state = match self.state { + Some(state) => state, + None => Arc::new(NetworkProxyState::new().await?), + }; + let current_cfg = state.current_cfg().await?; + let runtime = config::resolve_runtime(¤t_cfg)?; + // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only. + let (http_addr, admin_addr) = config::clamp_bind_addrs( + self.http_addr.unwrap_or(runtime.http_addr), + self.admin_addr.unwrap_or(runtime.admin_addr), + ¤t_cfg.network_proxy, + ); + + Ok(NetworkProxy { + state, + http_addr, + admin_addr, + policy_decider: self.policy_decider, + }) + } +} + +#[derive(Clone)] +pub struct NetworkProxy { + state: Arc, + http_addr: SocketAddr, + admin_addr: SocketAddr, + policy_decider: Option>, +} + +impl NetworkProxy { + pub fn builder() -> NetworkProxyBuilder { + NetworkProxyBuilder::default() + } + + pub async fn run(&self) -> Result { + let current_cfg = self.state.current_cfg().await?; + if !current_cfg.network_proxy.enabled { + warn!("network_proxy.enabled is false; skipping proxy listeners"); + return Ok(NetworkProxyHandle::noop()); + } + + if !unix_socket_permissions_supported() { + warn!("allowUnixSockets is macOS-only; requests will be rejected on this platform"); + } + + let http_task = tokio::spawn(http_proxy::run_http_proxy( + self.state.clone(), + self.http_addr, + self.policy_decider.clone(), + )); + let admin_task = tokio::spawn(admin::run_admin_api(self.state.clone(), self.admin_addr)); + + Ok(NetworkProxyHandle { + http_task: Some(http_task), + admin_task: Some(admin_task), + completed: false, + }) + } +} + +pub struct NetworkProxyHandle { + http_task: Option>>, + admin_task: Option>>, + completed: bool, +} + +impl NetworkProxyHandle { + fn noop() -> Self { + Self { + http_task: Some(tokio::spawn(async { Ok(()) })), + admin_task: Some(tokio::spawn(async { Ok(()) })), + completed: true, + } + } + + pub async fn wait(mut self) -> Result<()> { + let http_task = self.http_task.take().context("missing http proxy task")?; + let admin_task = self.admin_task.take().context("missing admin proxy task")?; + let http_result = http_task.await; + let admin_result = admin_task.await; + self.completed = true; + http_result??; + admin_result??; + Ok(()) + } + + pub async fn shutdown(mut self) -> Result<()> { + abort_tasks(self.http_task.take(), self.admin_task.take()).await; + self.completed = true; + Ok(()) + } +} + +async fn abort_tasks( + http_task: Option>>, + admin_task: Option>>, +) { + if let Some(http_task) = http_task { + http_task.abort(); + let _ = http_task.await; + } + if let Some(admin_task) = admin_task { + admin_task.abort(); + let _ = admin_task.await; + } +} + +impl Drop for NetworkProxyHandle { + fn drop(&mut self) { + if self.completed { + return; + } + let http_task = self.http_task.take(); + let admin_task = self.admin_task.take(); + tokio::spawn(async move { + abort_tasks(http_task, admin_task).await; + }); + } +} diff --git a/codex-rs/network-proxy/src/reasons.rs b/codex-rs/network-proxy/src/reasons.rs new file mode 100644 index 0000000000..99f7f742d0 --- /dev/null +++ b/codex-rs/network-proxy/src/reasons.rs @@ -0,0 +1,6 @@ +pub(crate) const REASON_DENIED: &str = "denied"; +pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed"; +pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed"; +pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local"; +pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied"; +pub(crate) const REASON_PROXY_DISABLED: &str = "proxy_disabled"; diff --git a/codex-rs/network-proxy/src/responses.rs b/codex-rs/network-proxy/src/responses.rs new file mode 100644 index 0000000000..d02d1910e8 --- /dev/null +++ b/codex-rs/network-proxy/src/responses.rs @@ -0,0 +1,67 @@ +use crate::reasons::REASON_DENIED; +use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED_LOCAL; +use rama_http::Body; +use rama_http::Response; +use rama_http::StatusCode; +use serde::Serialize; +use tracing::error; + +pub fn text_response(status: StatusCode, body: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(Body::from(body.to_string())) + .unwrap_or_else(|_| Response::new(Body::from(body.to_string()))) +} + +pub fn json_response(value: &T) -> Response { + let body = match serde_json::to_string(value) { + Ok(body) => body, + Err(err) => { + error!("failed to serialize JSON response: {err}"); + "{}".to_string() + } + }; + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap_or_else(|err| { + error!("failed to build JSON response: {err}"); + Response::new(Body::from("{}")) + }) +} + +pub fn blocked_header_value(reason: &str) -> &'static str { + match reason { + REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist", + REASON_DENIED => "blocked-by-denylist", + REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy", + _ => "blocked-by-policy", + } +} + +pub fn blocked_message(reason: &str) -> &'static str { + match reason { + REASON_NOT_ALLOWED => "Codex blocked this request: domain not in allowlist.", + REASON_NOT_ALLOWED_LOCAL => { + "Codex blocked this request: local/private addresses not allowed." + } + REASON_DENIED => "Codex blocked this request: domain denied by policy.", + REASON_METHOD_NOT_ALLOWED => { + "Codex blocked this request: method not allowed in limited mode." + } + _ => "Codex blocked this request by network policy.", + } +} + +pub fn blocked_text_response(reason: &str) -> Response { + Response::builder() + .status(StatusCode::FORBIDDEN) + .header("content-type", "text/plain") + .header("x-proxy-error", blocked_header_value(reason)) + .body(Body::from(blocked_message(reason))) + .unwrap_or_else(|_| Response::new(Body::from("blocked"))) +} diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs new file mode 100644 index 0000000000..49e34d5c0a --- /dev/null +++ b/codex-rs/network-proxy/src/runtime.rs @@ -0,0 +1,984 @@ +use crate::config::NetworkMode; +use crate::config::NetworkProxyConfig; +use crate::policy::Host; +use crate::policy::is_loopback_host; +use crate::policy::is_non_public_ip; +use crate::policy::normalize_host; +use crate::reasons::REASON_DENIED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED_LOCAL; +use crate::state::NetworkProxyConstraints; +use crate::state::build_config_state; +use crate::state::validate_policy_against_constraints; +use anyhow::Context; +use anyhow::Result; +use codex_utils_absolute_path::AbsolutePathBuf; +use globset::GlobSet; +use serde::Serialize; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::net::IpAddr; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use time::OffsetDateTime; +use tokio::net::lookup_host; +use tokio::sync::RwLock; +use tokio::time::timeout; +use tracing::info; +use tracing::warn; + +const MAX_BLOCKED_EVENTS: usize = 200; +const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostBlockReason { + Denied, + NotAllowed, + NotAllowedLocal, +} + +impl HostBlockReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::Denied => REASON_DENIED, + Self::NotAllowed => REASON_NOT_ALLOWED, + Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL, + } + } +} + +impl std::fmt::Display for HostBlockReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostBlockDecision { + Allowed, + Blocked(HostBlockReason), +} + +#[derive(Clone, Debug, Serialize)] +pub struct BlockedRequest { + pub host: String, + pub reason: String, + pub client: Option, + pub method: Option, + pub mode: Option, + pub protocol: String, + pub timestamp: i64, +} + +impl BlockedRequest { + pub fn new( + host: String, + reason: String, + client: Option, + method: Option, + mode: Option, + protocol: String, + ) -> Self { + Self { + host, + reason, + client, + method, + mode, + protocol, + timestamp: unix_timestamp(), + } + } +} + +#[derive(Clone)] +pub(crate) struct ConfigState { + pub(crate) config: NetworkProxyConfig, + pub(crate) allow_set: GlobSet, + pub(crate) deny_set: GlobSet, + pub(crate) constraints: NetworkProxyConstraints, + pub(crate) layer_mtimes: Vec, + pub(crate) cfg_path: PathBuf, + pub(crate) blocked: VecDeque, +} + +#[derive(Clone)] +pub(crate) struct LayerMtime { + pub(crate) path: PathBuf, + pub(crate) mtime: Option, +} + +impl LayerMtime { + pub(crate) fn new(path: PathBuf) -> Self { + let mtime = path.metadata().and_then(|m| m.modified()).ok(); + Self { path, mtime } + } +} + +#[derive(Clone)] +pub struct NetworkProxyState { + state: Arc>, +} + +impl std::fmt::Debug for NetworkProxyState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy + // and may contain sensitive paths. + f.debug_struct("NetworkProxyState").finish_non_exhaustive() + } +} + +impl NetworkProxyState { + pub async fn new() -> Result { + let cfg_state = build_config_state().await?; + Ok(Self { + state: Arc::new(RwLock::new(cfg_state)), + }) + } + + pub async fn current_cfg(&self) -> Result { + // Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to + // `config.toml` (including Codex-managed writes) take effect without a restart. + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.clone()) + } + + pub async fn current_patterns(&self) -> Result<(Vec, Vec)> { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(( + guard.config.network_proxy.policy.allowed_domains.clone(), + guard.config.network_proxy.policy.denied_domains.clone(), + )) + } + + pub async fn enabled(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.enabled) + } + + pub async fn force_reload(&self) -> Result<()> { + let (previous_cfg, cfg_path) = { + let guard = self.state.read().await; + (guard.config.clone(), guard.cfg_path.clone()) + }; + + match build_config_state().await { + Ok(mut new_state) => { + // Policy changes are operationally sensitive; logging diffs makes changes traceable + // without needing to dump full config blobs (which can include unrelated settings). + log_policy_changes(&previous_cfg, &new_state.config); + let mut guard = self.state.write().await; + new_state.blocked = guard.blocked.clone(); + *guard = new_state; + let path = guard.cfg_path.display(); + info!("reloaded config from {path}"); + Ok(()) + } + Err(err) => { + let path = cfg_path.display(); + warn!("failed to reload config from {path}: {err}; keeping previous config"); + Err(err) + } + } + } + + pub async fn host_blocked(&self, host: &str, port: u16) -> Result { + self.reload_if_needed().await?; + let host = match Host::parse(host) { + Ok(host) => host, + Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)), + }; + let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = { + let guard = self.state.read().await; + ( + guard.deny_set.clone(), + guard.allow_set.clone(), + guard.config.network_proxy.policy.allow_local_binding, + guard.config.network_proxy.policy.allowed_domains.is_empty(), + guard.config.network_proxy.policy.allowed_domains.clone(), + ) + }; + + let host_str = host.as_str(); + + // Decision order matters: + // 1) explicit deny always wins + // 2) local/private networking is opt-in (defense-in-depth) + // 3) allowlist is enforced when configured + if deny_set.is_match(host_str) { + return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied)); + } + + let is_allowlisted = allow_set.is_match(host_str); + if !allow_local_binding { + // If the intent is "prevent access to local/internal networks", we must not rely solely + // on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or + // public suffix services that map hostnames onto private IPs. + // + // We therefore do a best-effort DNS + IP classification check before allowing the + // request. Explicit local/loopback literals are allowed only when explicitly + // allowlisted; hostnames that resolve to local/private IPs are blocked even if + // allowlisted. + let local_literal = { + let host_no_scope = host_str + .split_once('%') + .map(|(ip, _)| ip) + .unwrap_or(host_str); + if is_loopback_host(&host) { + true + } else if let Ok(ip) = host_no_scope.parse::() { + is_non_public_ip(ip) + } else { + false + } + }; + + if local_literal { + if !is_explicit_local_allowlisted(&allowed_domains, &host) { + return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); + } + } else if host_resolves_to_non_public_ip(host_str, port).await { + return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); + } + } + + if allowed_domains_empty || !is_allowlisted { + Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)) + } else { + Ok(HostBlockDecision::Allowed) + } + } + + pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { + self.reload_if_needed().await?; + let mut guard = self.state.write().await; + guard.blocked.push_back(entry); + while guard.blocked.len() > MAX_BLOCKED_EVENTS { + guard.blocked.pop_front(); + } + Ok(()) + } + + /// Drain and return the buffered blocked-request entries in FIFO order. + pub async fn drain_blocked(&self) -> Result> { + self.reload_if_needed().await?; + let blocked = { + let mut guard = self.state.write().await; + std::mem::take(&mut guard.blocked) + }; + Ok(blocked.into_iter().collect()) + } + + pub async fn is_unix_socket_allowed(&self, path: &str) -> Result { + self.reload_if_needed().await?; + if !unix_socket_permissions_supported() { + return Ok(false); + } + + // We only support absolute unix socket paths (a relative path would be ambiguous with + // respect to the proxy process's CWD and can lead to confusing allowlist behavior). + let requested_path = Path::new(path); + if !requested_path.is_absolute() { + return Ok(false); + } + + let guard = self.state.read().await; + // Normalize the path while keeping the absolute-path requirement explicit. + let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) { + Ok(path) => path, + Err(_) => return Ok(false), + }; + let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); + for allowed in &guard.config.network_proxy.policy.allow_unix_sockets { + if allowed == path { + return Ok(true); + } + + // Best-effort canonicalization to reduce surprises with symlinks. + // If canonicalization fails (e.g., socket not created yet), fall back to raw comparison. + let Some(requested_canonical) = &requested_canonical else { + continue; + }; + if let Ok(allowed_canonical) = std::fs::canonicalize(allowed) + && &allowed_canonical == requested_canonical + { + return Ok(true); + } + } + Ok(false) + } + + pub async fn method_allowed(&self, method: &str) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.mode.allows_method(method)) + } + + pub async fn allow_upstream_proxy(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.allow_upstream_proxy) + } + + pub async fn network_mode(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.mode) + } + + pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> { + loop { + self.reload_if_needed().await?; + let (candidate, constraints) = { + let guard = self.state.read().await; + let mut candidate = guard.config.clone(); + candidate.network_proxy.mode = mode; + (candidate, guard.constraints.clone()) + }; + + validate_policy_against_constraints(&candidate, &constraints) + .context("network_proxy.mode constrained by managed config")?; + + let mut guard = self.state.write().await; + if guard.constraints != constraints { + drop(guard); + continue; + } + guard.config.network_proxy.mode = mode; + info!("updated network mode to {mode:?}"); + return Ok(()); + } + } + + async fn reload_if_needed(&self) -> Result<()> { + let needs_reload = { + let guard = self.state.read().await; + guard.layer_mtimes.iter().any(|layer| { + let metadata = std::fs::metadata(&layer.path).ok(); + match (metadata.and_then(|m| m.modified().ok()), layer.mtime) { + (Some(new_mtime), Some(old_mtime)) => new_mtime > old_mtime, + (Some(_), None) => true, + (None, Some(_)) => true, + (None, None) => false, + } + }) + }; + + if !needs_reload { + return Ok(()); + } + + self.force_reload().await + } +} + +pub(crate) fn unix_socket_permissions_supported() -> bool { + cfg!(target_os = "macos") +} + +async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool { + if let Ok(ip) = host.parse::() { + return is_non_public_ip(ip); + } + + // If DNS lookup fails, default to "not local/private" rather than blocking. In practice, the + // subsequent connect attempt will fail anyway, and blocking on transient resolver issues would + // make the proxy fragile. The allowlist/denylist remains the primary control plane. + let addrs = match timeout(DNS_LOOKUP_TIMEOUT, lookup_host((host, port))).await { + Ok(Ok(addrs)) => addrs, + Ok(Err(_)) | Err(_) => return false, + }; + + for addr in addrs { + if is_non_public_ip(addr.ip()) { + return true; + } + } + + false +} + +fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) { + log_domain_list_changes( + "allowlist", + &previous.network_proxy.policy.allowed_domains, + &next.network_proxy.policy.allowed_domains, + ); + log_domain_list_changes( + "denylist", + &previous.network_proxy.policy.denied_domains, + &next.network_proxy.policy.denied_domains, + ); +} + +fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) { + let previous_set: HashSet = previous + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + let next_set: HashSet = next + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + + let added = next_set + .difference(&previous_set) + .cloned() + .collect::>(); + let removed = previous_set + .difference(&next_set) + .cloned() + .collect::>(); + + let mut seen_next = HashSet::new(); + for entry in next { + let key = entry.to_ascii_lowercase(); + if seen_next.insert(key.clone()) && added.contains(&key) { + info!("config entry added to {list_name}: {entry}"); + } + } + + let mut seen_previous = HashSet::new(); + for entry in previous { + let key = entry.to_ascii_lowercase(); + if seen_previous.insert(key.clone()) && removed.contains(&key) { + info!("config entry removed from {list_name}: {entry}"); + } + } +} + +fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool { + let normalized_host = host.as_str(); + allowed_domains.iter().any(|pattern| { + let pattern = pattern.trim(); + if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") { + return false; + } + if pattern.contains('*') || pattern.contains('?') { + return false; + } + normalize_host(pattern) == normalized_host + }) +} + +fn unix_timestamp() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} + +#[cfg(test)] +pub(crate) fn network_proxy_state_for_policy( + policy: crate::config::NetworkPolicy, +) -> NetworkProxyState { + let config = NetworkProxyConfig { + network_proxy: crate::config::NetworkProxySettings { + enabled: true, + mode: NetworkMode::Full, + policy, + ..crate::config::NetworkProxySettings::default() + }, + }; + + let allow_set = + crate::policy::compile_globset(&config.network_proxy.policy.allowed_domains).unwrap(); + let deny_set = + crate::policy::compile_globset(&config.network_proxy.policy.denied_domains).unwrap(); + + let state = ConfigState { + config, + allow_set, + deny_set, + constraints: NetworkProxyConstraints::default(), + layer_mtimes: Vec::new(), + cfg_path: PathBuf::from("/nonexistent/config.toml"), + blocked: VecDeque::new(), + }; + + NetworkProxyState { + state: Arc::new(RwLock::new(state)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkPolicy; + use crate::config::NetworkProxyConfig; + use crate::config::NetworkProxySettings; + use crate::policy::compile_globset; + use crate::state::NetworkProxyConstraints; + use crate::state::validate_policy_against_constraints; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn host_blocked_denied_wins_over_allowed() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["example.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + + #[tokio::test] + async fn host_blocked_requires_allowlist_match() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + // Use a public IP literal to avoid relying on ambient DNS behavior (some networks + // resolve unknown hostnames to private IPs, which would trigger `not_allowed_local`). + state.host_blocked("8.8.8.8", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) + ); + } + + #[tokio::test] + async fn host_blocked_subdomain_wildcards_exclude_apex() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*.openai.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("api.openai.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("openai.com", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + assert_eq!( + state.host_blocked("localhost", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_allowlist_is_wildcard() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_private_ip_literal_when_allowlist_is_wildcard() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["localhost".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("localhost", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["10.0.0.1".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("fe80::1%lo0", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["fe80::1%lo0".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("fe80::1%lo0", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_allowlist_empty() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec![], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[test] + fn validate_policy_against_constraints_disallows_widening_allowed_domains() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["example.com".to_string(), "evil.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_widening_mode() { + let constraints = NetworkProxyConstraints { + mode: Some(NetworkMode::Limited), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["**.example.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_requires_managed_denied_domains_entries() { + let constraints = NetworkProxyConstraints { + denied_domains: Some(vec!["evil.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + denied_domains: vec![], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() { + let constraints = NetworkProxyConstraints { + enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() { + let constraints = NetworkProxyConstraints { + allow_local_binding: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allow_local_binding: true, + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_non_loopback_admin_without_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_non_loopback_admin: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + dangerously_allow_non_loopback_admin: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_non_loopback_admin_with_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_non_loopback_admin: Some(true), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + dangerously_allow_non_loopback_admin: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn compile_globset_is_case_insensitive() { + let patterns = vec!["ExAmPle.CoM".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("EXAMPLE.COM")); + } + + #[test] + fn compile_globset_excludes_apex_for_subdomain_patterns() { + let patterns = vec!["*.openai.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("api.openai.com")); + assert!(!set.is_match("openai.com")); + assert!(!set.is_match("evilopenai.com")); + } + + #[test] + fn compile_globset_includes_apex_for_double_wildcard_patterns() { + let patterns = vec!["**.openai.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("openai.com")); + assert!(set.is_match("api.openai.com")); + assert!(!set.is_match("evilopenai.com")); + } + + #[test] + fn compile_globset_matches_all_with_star() { + let patterns = vec!["*".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("openai.com")); + assert!(set.is_match("api.openai.com")); + } + + #[test] + fn compile_globset_dedupes_patterns_without_changing_behavior() { + let patterns = vec!["example.com".to_string(), "example.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("EXAMPLE.COM")); + assert!(!set.is_match("not-example.com")); + } + + #[test] + fn compile_globset_rejects_invalid_patterns() { + let patterns = vec!["[".to_string()]; + assert!(compile_globset(&patterns).is_err()); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allowlist_is_respected_on_macos() { + let socket_path = "/tmp/example.sock".to_string(); + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![socket_path.clone()], + ..NetworkPolicy::default() + }); + + assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap()); + assert!( + !state + .is_unix_socket_allowed("/tmp/not-allowed.sock") + .await + .unwrap() + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allowlist_resolves_symlinks() { + use std::os::unix::fs::symlink; + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let dir = temp_dir.path(); + + let real = dir.join("real.sock"); + let link = dir.join("link.sock"); + + // The allowlist mechanism is path-based; for test purposes we don't need an actual unix + // domain socket. Any filesystem entry works for canonicalization. + std::fs::write(&real, b"not a socket").unwrap(); + symlink(&real, &link).unwrap(); + + let real_s = real.to_str().unwrap().to_string(); + let link_s = link.to_str().unwrap().to_string(); + + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![real_s], + ..NetworkPolicy::default() + }); + + assert!(state.is_unix_socket_allowed(&link_s).await.unwrap()); + } + + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn unix_socket_allowlist_is_rejected_on_non_macos() { + let socket_path = "/tmp/example.sock".to_string(); + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![socket_path.clone()], + ..NetworkPolicy::default() + }); + + assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap()); + } +} diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs new file mode 100644 index 0000000000..e1e8a1e4e6 --- /dev/null +++ b/codex-rs/network-proxy/src/state.rs @@ -0,0 +1,419 @@ +use crate::config::NetworkMode; +use crate::config::NetworkProxyConfig; +use crate::policy::DomainPattern; +use crate::policy::compile_globset; +use crate::runtime::ConfigState; +use crate::runtime::LayerMtime; +use anyhow::Context; +use anyhow::Result; +use codex_app_server_protocol::ConfigLayerSource; +use codex_core::config::CONFIG_TOML_FILE; +use codex_core::config::Constrained; +use codex_core::config::ConstraintError; +use codex_core::config::find_codex_home; +use codex_core::config_loader::ConfigLayerStack; +use codex_core::config_loader::ConfigLayerStackOrdering; +use codex_core::config_loader::LoaderOverrides; +use codex_core::config_loader::RequirementSource; +use codex_core::config_loader::load_config_layers_state; +use serde::Deserialize; +use std::collections::HashSet; + +pub use crate::runtime::BlockedRequest; +pub use crate::runtime::NetworkProxyState; +#[cfg(test)] +pub(crate) use crate::runtime::network_proxy_state_for_policy; + +pub(crate) async fn build_config_state() -> Result { + // Load config through `codex-core` so we inherit the same layer ordering and semantics as the + // rest of Codex (system/managed layers, user layers, session flags, etc.). + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let cli_overrides = Vec::new(); + let overrides = LoaderOverrides::default(); + let config_layer_stack = load_config_layers_state(&codex_home, None, &cli_overrides, overrides) + .await + .context("failed to load Codex config")?; + + let cfg_path = codex_home.join(CONFIG_TOML_FILE); + + // Deserialize from the merged effective config, rather than parsing config.toml ourselves. + // This avoids a second parser/merger implementation (and the drift that comes with it). + let merged_toml = config_layer_stack.effective_config(); + let config: NetworkProxyConfig = merged_toml + .try_into() + .context("failed to deserialize network proxy config")?; + + // Security boundary: user-controlled layers must not be able to widen restrictions set by + // trusted/managed layers (e.g., MDM). Enforce this before building runtime state. + let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?; + + let layer_mtimes = collect_layer_mtimes(&config_layer_stack); + let deny_set = compile_globset(&config.network_proxy.policy.denied_domains)?; + let allow_set = compile_globset(&config.network_proxy.policy.allowed_domains)?; + Ok(ConfigState { + config, + allow_set, + deny_set, + constraints, + layer_mtimes, + cfg_path, + blocked: std::collections::VecDeque::new(), + }) +} + +fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec { + stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) + .iter() + .filter_map(|layer| { + let path = match &layer.name { + ConfigLayerSource::System { file } => Some(file.as_path().to_path_buf()), + ConfigLayerSource::User { file } => Some(file.as_path().to_path_buf()), + ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder + .join(CONFIG_TOML_FILE) + .ok() + .map(|p| p.as_path().to_path_buf()), + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => { + Some(file.as_path().to_path_buf()) + } + _ => None, + }; + path.map(LayerMtime::new) + }) + .collect() +} + +#[derive(Debug, Default, Deserialize)] +struct PartialConfig { + #[serde(default)] + network_proxy: PartialNetworkProxyConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct PartialNetworkProxyConfig { + enabled: Option, + mode: Option, + allow_upstream_proxy: Option, + dangerously_allow_non_loopback_proxy: Option, + dangerously_allow_non_loopback_admin: Option, + #[serde(default)] + policy: PartialNetworkPolicy, +} + +#[derive(Debug, Default, Deserialize)] +struct PartialNetworkPolicy { + #[serde(default)] + allowed_domains: Option>, + #[serde(default)] + denied_domains: Option>, + #[serde(default)] + allow_unix_sockets: Option>, + #[serde(default)] + allow_local_binding: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct NetworkProxyConstraints { + pub(crate) enabled: Option, + pub(crate) mode: Option, + pub(crate) allow_upstream_proxy: Option, + pub(crate) dangerously_allow_non_loopback_proxy: Option, + pub(crate) dangerously_allow_non_loopback_admin: Option, + pub(crate) allowed_domains: Option>, + pub(crate) denied_domains: Option>, + pub(crate) allow_unix_sockets: Option>, + pub(crate) allow_local_binding: Option, +} + +fn enforce_trusted_constraints( + layers: &codex_core::config_loader::ConfigLayerStack, + config: &NetworkProxyConfig, +) -> Result { + let constraints = network_proxy_constraints_from_trusted_layers(layers)?; + validate_policy_against_constraints(config, &constraints) + .context("network proxy constraints")?; + Ok(constraints) +} + +fn network_proxy_constraints_from_trusted_layers( + layers: &codex_core::config_loader::ConfigLayerStack, +) -> Result { + let mut constraints = NetworkProxyConstraints::default(); + for layer in layers.get_layers( + codex_core::config_loader::ConfigLayerStackOrdering::LowestPrecedenceFirst, + false, + ) { + // Only trusted layers contribute constraints. User-controlled layers can narrow policy but + // must never widen beyond what managed config allows. + if is_user_controlled_layer(&layer.name) { + continue; + } + + let partial: PartialConfig = layer + .config + .clone() + .try_into() + .context("failed to deserialize trusted config layer")?; + + if let Some(enabled) = partial.network_proxy.enabled { + constraints.enabled = Some(enabled); + } + if let Some(mode) = partial.network_proxy.mode { + constraints.mode = Some(mode); + } + if let Some(allow_upstream_proxy) = partial.network_proxy.allow_upstream_proxy { + constraints.allow_upstream_proxy = Some(allow_upstream_proxy); + } + if let Some(dangerously_allow_non_loopback_proxy) = + partial.network_proxy.dangerously_allow_non_loopback_proxy + { + constraints.dangerously_allow_non_loopback_proxy = + Some(dangerously_allow_non_loopback_proxy); + } + if let Some(dangerously_allow_non_loopback_admin) = + partial.network_proxy.dangerously_allow_non_loopback_admin + { + constraints.dangerously_allow_non_loopback_admin = + Some(dangerously_allow_non_loopback_admin); + } + + if let Some(allowed_domains) = partial.network_proxy.policy.allowed_domains { + constraints.allowed_domains = Some(allowed_domains); + } + if let Some(denied_domains) = partial.network_proxy.policy.denied_domains { + constraints.denied_domains = Some(denied_domains); + } + if let Some(allow_unix_sockets) = partial.network_proxy.policy.allow_unix_sockets { + constraints.allow_unix_sockets = Some(allow_unix_sockets); + } + if let Some(allow_local_binding) = partial.network_proxy.policy.allow_local_binding { + constraints.allow_local_binding = Some(allow_local_binding); + } + } + Ok(constraints) +} + +fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { + matches!( + layer, + ConfigLayerSource::User { .. } + | ConfigLayerSource::Project { .. } + | ConfigLayerSource::SessionFlags + ) +} + +pub(crate) fn validate_policy_against_constraints( + config: &NetworkProxyConfig, + constraints: &NetworkProxyConstraints, +) -> std::result::Result<(), ConstraintError> { + fn invalid_value( + field_name: &'static str, + candidate: impl Into, + allowed: impl Into, + ) -> ConstraintError { + ConstraintError::InvalidValue { + field_name, + candidate: candidate.into(), + allowed: allowed.into(), + requirement_source: RequirementSource::Unknown, + } + } + + let enabled = config.network_proxy.enabled; + if let Some(max_enabled) = constraints.enabled { + let _ = Constrained::new(enabled, move |candidate| { + if *candidate && !max_enabled { + Err(invalid_value( + "network_proxy.enabled", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + })?; + } + + if let Some(max_mode) = constraints.mode { + let _ = Constrained::new(config.network_proxy.mode, move |candidate| { + if network_mode_rank(*candidate) > network_mode_rank(max_mode) { + Err(invalid_value( + "network_proxy.mode", + format!("{candidate:?}"), + format!("{max_mode:?} or more restrictive"), + )) + } else { + Ok(()) + } + })?; + } + + let allow_upstream_proxy = constraints.allow_upstream_proxy; + let _ = Constrained::new( + config.network_proxy.allow_upstream_proxy, + move |candidate| match allow_upstream_proxy { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.allow_upstream_proxy", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + let allow_non_loopback_admin = constraints.dangerously_allow_non_loopback_admin; + let _ = Constrained::new( + config.network_proxy.dangerously_allow_non_loopback_admin, + move |candidate| match allow_non_loopback_admin { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.dangerously_allow_non_loopback_admin", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback_proxy; + let _ = Constrained::new( + config.network_proxy.dangerously_allow_non_loopback_proxy, + move |candidate| match allow_non_loopback_proxy { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.dangerously_allow_non_loopback_proxy", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + if let Some(allow_local_binding) = constraints.allow_local_binding { + let _ = Constrained::new( + config.network_proxy.policy.allow_local_binding, + move |candidate| { + if *candidate && !allow_local_binding { + Err(invalid_value( + "network_proxy.policy.allow_local_binding", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + }, + )?; + } + + if let Some(allowed_domains) = &constraints.allowed_domains { + let managed_patterns: Vec = allowed_domains + .iter() + .map(|entry| DomainPattern::parse_for_constraints(entry)) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.allowed_domains.clone(), + move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + let candidate_pattern = DomainPattern::parse_for_constraints(entry); + if !managed_patterns + .iter() + .any(|managed| managed.allows(&candidate_pattern)) + { + invalid.push(entry.clone()); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.allowed_domains", + format!("{invalid:?}"), + "subset of managed allowed_domains", + )) + } + }, + )?; + } + + if let Some(denied_domains) = &constraints.denied_domains { + let required_set: HashSet = denied_domains + .iter() + .map(|s| s.to_ascii_lowercase()) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.denied_domains.clone(), + move |candidate| { + let candidate_set: HashSet = + candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); + let missing: Vec = required_set + .iter() + .filter(|entry| !candidate_set.contains(*entry)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.denied_domains", + "missing managed denied_domains entries", + format!("{missing:?}"), + )) + } + }, + )?; + } + + if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets { + let allowed_set: HashSet = allow_unix_sockets + .iter() + .map(|s| s.to_ascii_lowercase()) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.allow_unix_sockets.clone(), + move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + if !allowed_set.contains(&entry.to_ascii_lowercase()) { + invalid.push(entry.clone()); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.allow_unix_sockets", + format!("{invalid:?}"), + "subset of managed allow_unix_sockets", + )) + } + }, + )?; + } + + Ok(()) +} + +fn network_mode_rank(mode: NetworkMode) -> u8 { + match mode { + NetworkMode::Limited => 0, + NetworkMode::Full => 1, + } +} diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs new file mode 100644 index 0000000000..9b69955bd4 --- /dev/null +++ b/codex-rs/network-proxy/src/upstream.rs @@ -0,0 +1,188 @@ +use rama_core::Layer; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::error::ErrorContext as _; +use rama_core::error::OpaqueError; +use rama_core::extensions::ExtensionsMut; +use rama_core::extensions::ExtensionsRef; +use rama_core::service::BoxService; +use rama_http::Body; +use rama_http::Request; +use rama_http::Response; +use rama_http::layer::version_adapter::RequestVersionAdapter; +use rama_http_backend::client::HttpClientService; +use rama_http_backend::client::HttpConnector; +use rama_http_backend::client::proxy::layer::HttpProxyConnectorLayer; +use rama_net::address::ProxyAddress; +use rama_net::client::EstablishedClientConnection; +use rama_net::http::RequestContext; +use rama_tcp::client::service::TcpConnector; +use rama_tls_boring::client::TlsConnectorDataBuilder; +use rama_tls_boring::client::TlsConnectorLayer; +use tracing::warn; + +#[cfg(target_os = "macos")] +use rama_unix::client::UnixConnector; + +#[derive(Clone, Default)] +struct ProxyConfig { + http: Option, + https: Option, + all: Option, +} + +impl ProxyConfig { + fn from_env() -> Self { + let http = read_proxy_env(&["HTTP_PROXY", "http_proxy"]); + let https = read_proxy_env(&["HTTPS_PROXY", "https_proxy"]); + let all = read_proxy_env(&["ALL_PROXY", "all_proxy"]); + Self { http, https, all } + } + + fn proxy_for_request(&self, req: &Request) -> Option { + let is_secure = RequestContext::try_from(req) + .map(|ctx| ctx.protocol.is_secure()) + .unwrap_or(false); + self.proxy_for_protocol(is_secure) + } + + fn proxy_for_protocol(&self, is_secure: bool) -> Option { + if is_secure { + self.https + .clone() + .or_else(|| self.http.clone()) + .or_else(|| self.all.clone()) + } else { + self.http.clone().or_else(|| self.all.clone()) + } + } +} + +fn read_proxy_env(keys: &[&str]) -> Option { + for key in keys { + let Ok(value) = std::env::var(key) else { + continue; + }; + let value = value.trim(); + if value.is_empty() { + continue; + } + match ProxyAddress::try_from(value) { + Ok(proxy) => { + if proxy + .protocol + .as_ref() + .map(rama_net::Protocol::is_http) + .unwrap_or(true) + { + return Some(proxy); + } + warn!("ignoring {key}: non-http proxy protocol"); + } + Err(err) => { + warn!("ignoring {key}: invalid proxy address ({err})"); + } + } + } + None +} + +pub(crate) fn proxy_for_connect() -> Option { + ProxyConfig::from_env().proxy_for_protocol(true) +} + +#[derive(Clone)] +pub(crate) struct UpstreamClient { + connector: BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, + >, + proxy_config: ProxyConfig, +} + +impl UpstreamClient { + pub(crate) fn direct() -> Self { + Self::new(ProxyConfig::default()) + } + + pub(crate) fn from_env_proxy() -> Self { + Self::new(ProxyConfig::from_env()) + } + + #[cfg(target_os = "macos")] + pub(crate) fn unix_socket(path: &str) -> Self { + let connector = build_unix_connector(path); + Self { + connector, + proxy_config: ProxyConfig::default(), + } + } + + fn new(proxy_config: ProxyConfig) -> Self { + let connector = build_http_connector(); + Self { + connector, + proxy_config, + } + } +} + +impl Service> for UpstreamClient { + type Output = Response; + type Error = OpaqueError; + + async fn serve(&self, mut req: Request) -> Result { + if let Some(proxy) = self.proxy_config.proxy_for_request(&req) { + req.extensions_mut().insert(proxy); + } + + let uri = req.uri().clone(); + let EstablishedClientConnection { + input: mut req, + conn: http_connection, + } = self + .connector + .serve(req) + .await + .map_err(OpaqueError::from_boxed)?; + + req.extensions_mut() + .extend(http_connection.extensions().clone()); + + http_connection + .serve(req) + .await + .map_err(OpaqueError::from_boxed) + .with_context(|| format!("http request failure for uri: {uri}")) + } +} + +fn build_http_connector() -> BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, +> { + let transport = TcpConnector::default(); + let proxy = HttpProxyConnectorLayer::optional().into_layer(transport); + let tls_config = TlsConnectorDataBuilder::new_http_auto().into_shared_builder(); + let tls = TlsConnectorLayer::auto() + .with_connector_data(tls_config) + .into_layer(proxy); + let tls = RequestVersionAdapter::new(tls); + let connector = HttpConnector::new(tls); + connector.boxed() +} + +#[cfg(target_os = "macos")] +fn build_unix_connector( + path: &str, +) -> BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, +> { + let transport = UnixConnector::fixed(path); + let connector = HttpConnector::new(transport); + connector.boxed() +} diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 374a9d1b11..ae956797ea 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -156,6 +156,7 @@ pub enum AltScreenMode { #[serde(rename_all = "snake_case")] pub enum ModeKind { Plan, + Code, PairProgramming, Execute, Custom, @@ -163,61 +164,70 @@ pub enum ModeKind { /// Collaboration mode for a Codex session. #[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "mode", rename_all = "lowercase")] -pub enum CollaborationMode { - Plan(Settings), - PairProgramming(Settings), - Execute(Settings), - Custom(Settings), +#[serde(rename_all = "lowercase")] +pub struct CollaborationMode { + pub mode: ModeKind, + pub settings: Settings, } impl CollaborationMode { - /// Returns a reference to the settings, regardless of variant. - fn settings(&self) -> &Settings { - match self { - CollaborationMode::Plan(settings) - | CollaborationMode::PairProgramming(settings) - | CollaborationMode::Execute(settings) - | CollaborationMode::Custom(settings) => settings, - } + /// Returns a reference to the settings. + fn settings_ref(&self) -> &Settings { + &self.settings } pub fn model(&self) -> &str { - self.settings().model.as_str() + self.settings_ref().model.as_str() } pub fn reasoning_effort(&self) -> Option { - self.settings().reasoning_effort + self.settings_ref().reasoning_effort } /// Updates the collaboration mode with new model and/or effort values. /// /// - `model`: `Some(s)` to update the model, `None` to keep the current model /// - `effort`: `Some(Some(e))` to set effort to `e`, `Some(None)` to clear effort, `None` to keep current effort - /// - `developer_instructions`: `Some(s)` to update developer instructions, `None` to keep current + /// - `developer_instructions`: `Some(Some(s))` to set instructions, `Some(None)` to clear them, `None` to keep current /// - /// Returns a new `CollaborationMode` with updated values, preserving the variant. + /// Returns a new `CollaborationMode` with updated values, preserving the mode. pub fn with_updates( &self, model: Option, effort: Option>, - developer_instructions: Option, + developer_instructions: Option>, ) -> Self { - let settings = self.settings(); + let settings = self.settings_ref(); let updated_settings = Settings { model: model.unwrap_or_else(|| settings.model.clone()), reasoning_effort: effort.unwrap_or(settings.reasoning_effort), developer_instructions: developer_instructions - .or_else(|| settings.developer_instructions.clone()), + .unwrap_or_else(|| settings.developer_instructions.clone()), }; - match self { - CollaborationMode::Plan(_) => CollaborationMode::Plan(updated_settings), - CollaborationMode::PairProgramming(_) => { - CollaborationMode::PairProgramming(updated_settings) - } - CollaborationMode::Execute(_) => CollaborationMode::Execute(updated_settings), - CollaborationMode::Custom(_) => CollaborationMode::Custom(updated_settings), + CollaborationMode { + mode: self.mode, + settings: updated_settings, + } + } + + /// Applies a mask to this collaboration mode, returning a new collaboration mode + /// with the mask values applied. Fields in the mask that are `Some` will override + /// the corresponding fields, while `None` values will preserve the original values. + /// + /// The `name` field in the mask is ignored as it's metadata for the mask itself. + pub fn apply_mask(&self, mask: &CollaborationModeMask) -> Self { + let settings = self.settings_ref(); + CollaborationMode { + mode: mask.mode.unwrap_or(self.mode), + settings: Settings { + model: mask.model.clone().unwrap_or_else(|| settings.model.clone()), + reasoning_effort: mask.reasoning_effort.unwrap_or(settings.reasoning_effort), + developer_instructions: mask + .developer_instructions + .clone() + .unwrap_or_else(|| settings.developer_instructions.clone()), + }, } } } @@ -229,3 +239,49 @@ pub struct Settings { pub reasoning_effort: Option, pub developer_instructions: Option, } + +/// A mask for collaboration mode settings, allowing partial updates. +/// All fields except `name` are optional, enabling selective updates. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)] +pub struct CollaborationModeMask { + pub name: String, + pub mode: Option, + pub model: Option, + pub reasoning_effort: Option>, + pub developer_instructions: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn apply_mask_can_clear_optional_fields() { + let mode = CollaborationMode { + mode: ModeKind::Code, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: Some("stay focused".to_string()), + }, + }; + let mask = CollaborationModeMask { + name: "Clear".to_string(), + mode: None, + model: None, + reasoning_effort: Some(None), + developer_instructions: Some(None), + }; + + let expected = CollaborationMode { + mode: ModeKind::Code, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; + assert_eq!(expected, mode.apply_mask(&mask)); + } +} diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index f173d39645..57241bb8b6 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -263,13 +263,8 @@ impl DeveloperInstructions { /// Returns developer instructions from a collaboration mode if they exist and are non-empty. pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option { - let settings = match collaboration_mode { - CollaborationMode::Plan(settings) - | CollaborationMode::PairProgramming(settings) - | CollaborationMode::Execute(settings) - | CollaborationMode::Custom(settings) => settings, - }; - settings + collaboration_mode + .settings .developer_instructions .as_ref() .filter(|instructions| !instructions.is_empty()) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1f35fed60e..7bf3c90eab 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1518,6 +1518,10 @@ pub enum SessionSource { pub enum SubAgentSource { Review, Compact, + ThreadSpawn { + parent_thread_id: ThreadId, + depth: i32, + }, Other(String), } @@ -1539,6 +1543,12 @@ impl fmt::Display for SubAgentSource { match self { SubAgentSource::Review => f.write_str("review"), SubAgentSource::Compact => f.write_str("compact"), + SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + } => { + write!(f, "thread_spawn_{parent_thread_id}_d{depth}") + } SubAgentSource::Other(other) => f.write_str(other), } } @@ -2139,7 +2149,9 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, - pub rollout_path: PathBuf, + /// Path in which the rollout is stored. Can be `None` for ephemeral threads + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_path: Option, } /// User's decision in response to an ExecApprovalRequest. @@ -2491,7 +2503,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }), }; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8f945436b6..1d53f5d70d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,8 +7,12 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; +use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::external_editor; @@ -28,31 +32,40 @@ use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; +use codex_app_server_protocol::ConfigLayerSource; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config_loader::ConfigLayerStackOrdering; #[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::protocol::AskForApproval; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_otel::OtelManager; use codex_protocol::ThreadId; +use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::SessionConfiguredEvent; +use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -64,6 +77,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; @@ -74,10 +88,15 @@ use std::thread; use std::time::Duration; use std::time::Instant; use tokio::select; +use tokio::sync::Mutex; use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; +use toml::Value as TomlValue; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; +const THREAD_EVENT_CHANNEL_CAPACITY: usize = 1024; #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -162,12 +181,162 @@ fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option, } +#[derive(Debug, Clone)] +struct ThreadEventSnapshot { + session_configured: Option, + events: Vec, +} + +#[derive(Debug)] +struct ThreadEventStore { + session_configured: Option, + buffer: VecDeque, + user_message_ids: HashSet, + capacity: usize, + active: bool, +} + +impl ThreadEventStore { + fn new(capacity: usize) -> Self { + Self { + session_configured: None, + buffer: VecDeque::new(), + user_message_ids: HashSet::new(), + capacity, + active: false, + } + } + + fn new_with_session_configured(capacity: usize, event: Event) -> Self { + let mut store = Self::new(capacity); + store.session_configured = Some(event); + store + } + + fn push_event(&mut self, event: Event) { + match &event.msg { + EventMsg::SessionConfigured(_) => { + self.session_configured = Some(event); + return; + } + EventMsg::ItemCompleted(completed) => { + if let TurnItem::UserMessage(item) = &completed.item { + if !event.id.is_empty() && self.user_message_ids.contains(&event.id) { + return; + } + let legacy = Event { + id: event.id, + msg: item.as_legacy_event(), + }; + self.push_legacy_event(legacy); + return; + } + } + _ => {} + } + + self.push_legacy_event(event); + } + + fn push_legacy_event(&mut self, event: Event) { + if let EventMsg::UserMessage(_) = &event.msg + && !event.id.is_empty() + && !self.user_message_ids.insert(event.id.clone()) + { + return; + } + self.buffer.push_back(event); + if self.buffer.len() > self.capacity + && let Some(removed) = self.buffer.pop_front() + && matches!(removed.msg, EventMsg::UserMessage(_)) + && !removed.id.is_empty() + { + self.user_message_ids.remove(&removed.id); + } + } + + fn snapshot(&self) -> ThreadEventSnapshot { + ThreadEventSnapshot { + session_configured: self.session_configured.clone(), + events: self.buffer.iter().cloned().collect(), + } + } +} + +#[derive(Debug)] +struct ThreadEventChannel { + sender: mpsc::Sender, + receiver: Option>, + store: Arc>, +} + +impl ThreadEventChannel { + fn new(capacity: usize) -> Self { + let (sender, receiver) = mpsc::channel(capacity); + Self { + sender, + receiver: Some(receiver), + store: Arc::new(Mutex::new(ThreadEventStore::new(capacity))), + } + } + + fn new_with_session_configured(capacity: usize, event: Event) -> Self { + let (sender, receiver) = mpsc::channel(capacity); + Self { + sender, + receiver: Some(receiver), + store: Arc::new(Mutex::new(ThreadEventStore::new_with_session_configured( + capacity, event, + ))), + } + } +} + fn should_show_model_migration_prompt( current_model: &str, target_model: &str, @@ -336,6 +505,10 @@ pub(crate) struct App { /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) active_profile: Option, + cli_kv_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + runtime_approval_policy_override: Option, + runtime_sandbox_policy_override: Option, pub(crate) file_search: FileSearchManager, @@ -368,12 +541,12 @@ pub(crate) struct App { windows_sandbox: WindowsSandboxState, - // TODO(jif) drop once new UX is here. - // Track external agent approvals spawned via AgentControl. - /// Map routed approval IDs to their originating external threads and original IDs. - external_approval_routes: HashMap, - /// Buffered Codex events while external approvals are pending. - paused_codex_events: VecDeque, + thread_event_channels: HashMap, + active_thread_id: Option, + active_thread_rx: Option>, + primary_thread_id: Option, + primary_session_configured: Option, + pending_primary_events: VecDeque, } #[derive(Default)] @@ -383,6 +556,23 @@ struct WindowsSandboxState { skip_world_writable_scan_once: bool, } +fn normalize_harness_overrides_for_cwd( + mut overrides: ConfigOverrides, + base_cwd: &Path, +) -> Result { + if overrides.additional_writable_roots.is_empty() { + return Ok(overrides); + } + + let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len()); + for root in overrides.additional_writable_roots.drain(..) { + let absolute = AbsolutePathBuf::resolve_path_against_base(root, base_cwd)?; + normalized.push(absolute.into_path_buf()); + } + overrides.additional_writable_roots = normalized; + Ok(overrides) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -405,6 +595,38 @@ impl App { } } + async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { + let mut overrides = self.harness_overrides.clone(); + overrides.cwd = Some(cwd.clone()); + let cwd_display = cwd.display().to_string(); + ConfigBuilder::default() + .codex_home(self.config.codex_home.clone()) + .cli_overrides(self.cli_kv_overrides.clone()) + .harness_overrides(overrides) + .build() + .await + .wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}")) + } + + fn apply_runtime_policy_overrides(&mut self, config: &mut Config) { + if let Some(policy) = self.runtime_approval_policy_override.as_ref() + && let Err(err) = config.approval_policy.set(*policy) + { + tracing::warn!(%err, "failed to carry forward approval policy override"); + self.chat_widget.add_error_message(format!( + "Failed to carry forward approval policy override: {err}" + )); + } + if let Some(policy) = self.runtime_sandbox_policy_override.as_ref() + && let Err(err) = config.sandbox_policy.set(policy.clone()) + { + tracing::warn!(%err, "failed to carry forward sandbox policy override"); + self.chat_widget.add_error_message(format!( + "Failed to carry forward sandbox policy override: {err}" + )); + } + } + async fn shutdown_current_thread(&mut self) { if let Some(thread_id) = self.chat_widget.thread_id() { // Clear any in-flight rollback guard when switching threads. @@ -415,11 +637,255 @@ impl App { } } + fn ensure_thread_channel(&mut self, thread_id: ThreadId) -> &mut ThreadEventChannel { + self.thread_event_channels + .entry(thread_id) + .or_insert_with(|| ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY)) + } + + async fn set_thread_active(&mut self, thread_id: ThreadId, active: bool) { + if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) { + let mut store = channel.store.lock().await; + store.active = active; + } + } + + async fn activate_thread_channel(&mut self, thread_id: ThreadId) { + if self.active_thread_id.is_some() { + return; + } + self.set_thread_active(thread_id, true).await; + let receiver = if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) { + channel.receiver.take() + } else { + None + }; + self.active_thread_id = Some(thread_id); + self.active_thread_rx = receiver; + } + + async fn store_active_thread_receiver(&mut self) { + let Some(active_id) = self.active_thread_id else { + return; + }; + let Some(receiver) = self.active_thread_rx.take() else { + return; + }; + if let Some(channel) = self.thread_event_channels.get_mut(&active_id) { + let mut store = channel.store.lock().await; + store.active = false; + channel.receiver = Some(receiver); + } + } + + async fn activate_thread_for_replay( + &mut self, + thread_id: ThreadId, + ) -> Option<(mpsc::Receiver, ThreadEventSnapshot)> { + let channel = self.thread_event_channels.get_mut(&thread_id)?; + let receiver = channel.receiver.take()?; + let mut store = channel.store.lock().await; + store.active = true; + let snapshot = store.snapshot(); + Some((receiver, snapshot)) + } + + async fn clear_active_thread(&mut self) { + if let Some(active_id) = self.active_thread_id.take() { + self.set_thread_active(active_id, false).await; + } + self.active_thread_rx = None; + } + + async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> { + let (sender, store) = { + let channel = self.ensure_thread_channel(thread_id); + (channel.sender.clone(), Arc::clone(&channel.store)) + }; + + let should_send = { + let mut guard = store.lock().await; + guard.push_event(event.clone()); + guard.active + }; + + if should_send && let Err(err) = sender.send(event).await { + tracing::warn!("thread {thread_id} event channel closed: {err}"); + } + Ok(()) + } + + async fn enqueue_primary_event(&mut self, event: Event) -> Result<()> { + if let Some(thread_id) = self.primary_thread_id { + return self.enqueue_thread_event(thread_id, event).await; + } + + if let EventMsg::SessionConfigured(session) = &event.msg { + let thread_id = session.session_id; + self.primary_thread_id = Some(thread_id); + self.primary_session_configured = Some(session.clone()); + self.ensure_thread_channel(thread_id); + self.activate_thread_channel(thread_id).await; + + let pending = std::mem::take(&mut self.pending_primary_events); + for pending_event in pending { + self.enqueue_thread_event(thread_id, pending_event).await?; + } + self.enqueue_thread_event(thread_id, event).await?; + } else { + self.pending_primary_events.push_back(event); + } + Ok(()) + } + + fn open_agent_picker(&mut self) { + if self.thread_event_channels.is_empty() { + self.chat_widget + .add_info_message("No agents available yet.".to_string(), None); + return; + } + + let mut thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); + thread_ids.sort_by_key(ToString::to_string); + + let mut initial_selected_idx = None; + let items: Vec = thread_ids + .iter() + .enumerate() + .map(|(idx, thread_id)| { + if self.active_thread_id == Some(*thread_id) { + initial_selected_idx = Some(idx); + } + let id = *thread_id; + SelectionItem { + name: thread_id.to_string(), + is_current: self.active_thread_id == Some(*thread_id), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SelectAgentThread(id)); + })], + dismiss_on_select: true, + search_value: Some(thread_id.to_string()), + ..Default::default() + } + }) + .collect(); + + self.chat_widget.show_selection_view(SelectionViewParams { + title: Some("Agents".to_string()), + subtitle: Some("Select a thread to focus".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + }); + } + + async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> { + if self.active_thread_id == Some(thread_id) { + return Ok(()); + } + + let thread = match self.server.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to attach to agent thread {thread_id}: {err}" + )); + return Ok(()); + } + }; + + let previous_thread_id = self.active_thread_id; + self.store_active_thread_receiver().await; + self.active_thread_id = None; + let Some((receiver, snapshot)) = self.activate_thread_for_replay(thread_id).await else { + self.chat_widget + .add_error_message(format!("Agent thread {thread_id} is already active.")); + if let Some(previous_thread_id) = previous_thread_id { + self.activate_thread_channel(previous_thread_id).await; + } + return Ok(()); + }; + + self.active_thread_id = Some(thread_id); + self.active_thread_rx = Some(receiver); + + let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + let codex_op_tx = crate::chatwidget::spawn_op_forwarder(thread); + self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); + + self.reset_for_thread_switch(tui)?; + self.replay_thread_snapshot(snapshot); + self.drain_active_thread_events(tui).await?; + + Ok(()) + } + + fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.overlay = None; + self.transcript_cells.clear(); + self.deferred_history_lines.clear(); + self.has_emitted_history_lines = false; + self.backtrack = BacktrackState::default(); + self.backtrack_render_pending = false; + tui.terminal.clear_scrollback()?; + tui.terminal.clear()?; + Ok(()) + } + + fn reset_thread_event_state(&mut self) { + self.thread_event_channels.clear(); + self.active_thread_id = None; + self.active_thread_rx = None; + self.primary_thread_id = None; + self.pending_primary_events.clear(); + } + + async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { + let Some(mut rx) = self.active_thread_rx.take() else { + return Ok(()); + }; + + let mut disconnected = false; + loop { + match rx.try_recv() { + Ok(event) => self.handle_codex_event_now(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + + if !disconnected { + self.active_thread_rx = Some(rx); + } else { + self.clear_active_thread().await; + } + + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); + } + Ok(()) + } + + fn replay_thread_snapshot(&mut self, snapshot: ThreadEventSnapshot) { + if let Some(event) = snapshot.session_configured { + self.handle_codex_event_replay(event); + } + for event in snapshot.events { + self.handle_codex_event_replay(event); + } + } + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, mut config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -432,7 +898,10 @@ impl App { let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); + emit_project_config_warnings(&app_event_tx, &config); + let harness_overrides = + normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; let thread_manager = Arc::new(ThreadManager::new( config.codex_home.clone(), auth_manager.clone(), @@ -574,6 +1043,10 @@ impl App { auth_manager: auth_manager.clone(), config, active_profile, + cli_kv_overrides, + harness_overrides, + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, file_search, enhanced_keys_supported, transcript_cells: Vec::new(), @@ -587,8 +1060,12 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), - external_approval_routes: HashMap::new(), - paused_codex_events: VecDeque::new(), + thread_event_channels: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -649,6 +1126,20 @@ impl App { Some(event) = app_event_rx.recv() => { app.handle_event(tui, event).await? } + active = async { + if let Some(rx) = app.active_thread_rx.as_mut() { + rx.recv().await + } else { + None + } + }, if app.active_thread_rx.is_some() => { + if let Some(event) = active { + app.handle_active_thread_event(tui, event)?; + } else { + app.clear_active_thread().await; + } + AppRunControl::Continue + } Some(event) = tui_events.next() => { app.handle_tui_event(tui, event).await? } @@ -759,6 +1250,7 @@ impl App { otel_manager: self.otel_manager.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -779,6 +1271,34 @@ impl App { .await? { SessionSelection::Resume(path) => { + let current_cwd = self.config.cwd.clone(); + let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( + tui, + ¤t_cwd, + &path, + CwdPromptAction::Resume, + true, + ) + .await? + { + Some(cwd) => cwd, + None => current_cwd.clone(), + }; + let mut resume_config = if crate::cwds_differ(¤t_cwd, &resume_cwd) { + match self.rebuild_config_for_cwd(resume_cwd).await { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + } + } else { + // No rebuild needed: current_cwd comes from self.config.cwd. + self.config.clone() + }; + self.apply_runtime_policy_overrides(&mut resume_config); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), @@ -786,7 +1306,7 @@ impl App { match self .server .resume_thread_from_rollout( - self.config.clone(), + resume_config.clone(), path.clone(), self.auth_manager.clone(), ) @@ -794,6 +1314,11 @@ impl App { { Ok(resumed) => { self.shutdown_current_thread().await; + self.config = resume_config; + self.file_search = FileSearchManager::new( + self.config.cwd.clone(), + self.app_event_tx.clone(), + ); let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), @@ -803,6 +1328,7 @@ impl App { resumed.thread, resumed.session_configured, ); + self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -852,6 +1378,7 @@ impl App { forked.thread, forked.session_configured, ); + self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -928,18 +1455,7 @@ impl App { self.chat_widget.on_commit_tick(); } AppEvent::CodexEvent(event) => { - if !self.external_approval_routes.is_empty() { - // Store the events while the approval is pending. - self.paused_codex_events.push_back(event); - return Ok(AppRunControl::Continue); - } - self.handle_codex_event_now(event); - if self.backtrack_render_pending { - tui.frame_requester().schedule_frame(); - } - } - AppEvent::ExternalApprovalRequest { thread_id, event } => { - self.handle_external_approval_request(thread_id, event); + self.enqueue_primary_event(event).await?; } AppEvent::Exit(mode) => match mode { ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), @@ -950,72 +1466,9 @@ impl App { AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } - AppEvent::CodexOp(op) => match op { - // Catch potential approvals coming from an external thread and treat them - // directly. This support both command and patch approval. In such case - // the approval get transferred to the corresponding thread and the external - // approval map (`external_approval_routes`) is updated. - Op::ExecApproval { id, decision } => { - if let Some((thread_id, original_id)) = - self.external_approval_routes.remove(&id) - { - // Approval of a sub-agent. - self.forward_external_op( - thread_id, - Op::ExecApproval { - id: original_id, - decision, - }, - ) - .await; - self.finish_external_approval(); - } else { - // This is an approval but not external. - self.chat_widget - .submit_op(Op::ExecApproval { id, decision }); - } - } - Op::PatchApproval { id, decision } => { - if let Some((thread_id, original_id)) = - self.external_approval_routes.remove(&id) - { - // Approval of a sub-agent. - self.forward_external_op( - thread_id, - Op::PatchApproval { - id: original_id, - decision, - }, - ) - .await; - self.finish_external_approval(); - } else { - // This is an approval but not external. - self.chat_widget - .submit_op(Op::PatchApproval { id, decision }); - } - } - Op::UserInputAnswer { id, response } => { - if let Some((thread_id, original_id)) = - self.external_approval_routes.remove(&id) - { - self.forward_external_op( - thread_id, - Op::UserInputAnswer { - id: original_id, - response, - }, - ) - .await; - self.finish_external_approval(); - } else { - self.chat_widget - .submit_op(Op::UserInputAnswer { id, response }); - } - } - // Standard path where this is not an external approval response. - _ => self.chat_widget.submit_op(op), - }, + AppEvent::CodexOp(op) => { + self.chat_widget.submit_op(op); + } AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); @@ -1049,10 +1502,8 @@ impl App { AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); } - AppEvent::UpdateCollaborationMode(mode) => { - let model = mode.model().to_string(); - self.chat_widget.set_collaboration_mode(mode); - self.chat_widget.set_model(&model); + AppEvent::UpdateCollaborationMode(mask) => { + self.chat_widget.set_collaboration_mask(mask); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1308,6 +1759,13 @@ impl App { } } AppEvent::UpdateAskForApprovalPolicy(policy) => { + self.runtime_approval_policy_override = Some(policy); + if let Err(err) = self.config.approval_policy.set(policy) { + tracing::warn!(%err, "failed to set approval policy on app config"); + self.chat_widget + .add_error_message(format!("Failed to set approval policy: {err}")); + return Ok(AppRunControl::Continue); + } self.chat_widget.set_approval_policy(policy); } AppEvent::UpdateSandboxPolicy(policy) => { @@ -1336,6 +1794,8 @@ impl App { .add_error_message(format!("Failed to set sandbox policy: {err}")); return Ok(AppRunControl::Continue); } + self.runtime_sandbox_policy_override = + Some(self.config.sandbox_policy.get().clone()); // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. #[cfg(target_os = "windows")] @@ -1481,6 +1941,12 @@ impl App { AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } + AppEvent::OpenAgentPicker => { + self.open_agent_picker(); + } + AppEvent::SelectAgentThread(thread_id) => { + self.select_agent_thread(tui, thread_id).await?; + } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); } @@ -1584,56 +2050,23 @@ impl App { self.chat_widget.handle_codex_event(event); } - /// Routes external approval request events through the chat widget by - /// rewriting the event id to include the originating thread. - /// - /// `thread_id` is the external thread that issued the approval request. - /// `event` is the approval request event whose id is rewritten so replies - /// can be routed back to the correct thread. - fn handle_external_approval_request(&mut self, thread_id: ThreadId, mut event: Event) { - match &mut event.msg { - EventMsg::RequestUserInput(ev) => { - let original_id = ev.turn_id.clone(); - let routing_id = format!("{thread_id}:{original_id}"); - self.external_approval_routes - .insert(routing_id.clone(), (thread_id, original_id)); - ev.turn_id = routing_id.clone(); - event.id = routing_id; - } - EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { - let original_id = event.id.clone(); - let routing_id = format!("{thread_id}:{original_id}"); - self.external_approval_routes - .insert(routing_id.clone(), (thread_id, original_id)); - event.id = routing_id; - } - _ => return, - } - self.chat_widget.handle_codex_event(event); + fn handle_codex_event_replay(&mut self, event: Event) { + self.handle_backtrack_event(&event.msg); + self.chat_widget.handle_codex_event_replay(event); } - async fn forward_external_op(&self, thread_id: ThreadId, op: Op) { - let thread = match self.server.get_thread(thread_id).await { - Ok(thread) => thread, - Err(err) => { - tracing::warn!("failed to find thread {thread_id} for approval response: {err}"); - return; - } - }; - if let Err(err) = thread.submit(op).await { - tracing::warn!("failed to submit approval response to thread {thread_id}: {err}"); - } - } - - fn finish_external_approval(&mut self) { - if self.external_approval_routes.is_empty() { - while let Some(event) = self.paused_codex_events.pop_front() { - self.handle_codex_event_now(event); - } + fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> { + self.handle_codex_event_now(event); + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); } + Ok(()) } async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> { + if self.thread_event_channels.contains_key(&thread_id) { + return Ok(()); + } let thread = match self.server.get_thread(thread_id).await { Ok(thread) => thread, Err(err) => { @@ -1641,7 +2074,15 @@ impl App { return Ok(()); } }; - let app_event_tx = self.app_event_tx.clone(); + let event = Event { + id: String::new(), + msg: EventMsg::SessionConfigured(self.session_configured_for_thread(thread_id)), + }; + let channel = + ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event); + let sender = channel.sender.clone(); + let store = Arc::clone(&channel.store); + self.thread_event_channels.insert(thread_id, channel); tokio::spawn(async move { loop { let event = match thread.next_event().await { @@ -1651,19 +2092,47 @@ impl App { break; } }; - match event.msg { - EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::RequestUserInput(_) => { - app_event_tx.send(AppEvent::ExternalApprovalRequest { thread_id, event }); - } - _ => {} + let should_send = { + let mut guard = store.lock().await; + guard.push_event(event.clone()); + guard.active + }; + if should_send && let Err(err) = sender.send(event).await { + tracing::debug!("external thread {thread_id} channel closed: {err}"); + break; } } }); Ok(()) } + fn session_configured_for_thread(&self, thread_id: ThreadId) -> SessionConfiguredEvent { + let mut session_configured = + self.primary_session_configured + .clone() + .unwrap_or_else(|| SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + model: self.chat_widget.current_model().to_string(), + model_provider_id: self.config.model_provider_id.clone(), + approval_policy: *self.config.approval_policy.get(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + cwd: self.config.cwd.clone(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: Some(PathBuf::new()), + }); + session_configured.session_id = thread_id; + session_configured.forked_from_id = None; + session_configured.history_log_id = 0; + session_configured.history_entry_count = 0; + session_configured.initial_messages = None; + session_configured.rollout_path = Some(PathBuf::new()); + session_configured + } + fn reasoning_label(reasoning_effort: Option) -> &'static str { match reasoning_effort { Some(ReasoningEffortConfig::Minimal) => "minimal", @@ -1875,6 +2344,7 @@ mod tests { use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; + use codex_core::config::ConfigOverrides; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; @@ -1884,6 +2354,7 @@ mod tests { use codex_core::protocol::SessionSource; use codex_otel::OtelManager; use codex_protocol::ThreadId; + use codex_protocol::user_input::TextElement; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; @@ -1892,6 +2363,25 @@ mod tests { use std::sync::atomic::AtomicBool; use tempfile::tempdir; + #[test] + fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { + let temp_dir = tempdir()?; + let base_cwd = temp_dir.path().join("base"); + std::fs::create_dir_all(&base_cwd)?; + + let overrides = ConfigOverrides { + additional_writable_roots: vec![PathBuf::from("rel")], + ..Default::default() + }; + let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?; + + assert_eq!( + normalized.additional_writable_roots, + vec![base_cwd.join("rel")] + ); + Ok(()) + } + async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); @@ -1913,6 +2403,10 @@ mod tests { auth_manager, config, active_profile: None, + cli_kv_overrides: Vec::new(), + harness_overrides: ConfigOverrides::default(), + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, file_search, transcript_cells: Vec::new(), overlay: None, @@ -1926,8 +2420,12 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), - external_approval_routes: HashMap::new(), - paused_codex_events: VecDeque::new(), + thread_event_channels: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), } } @@ -1957,6 +2455,10 @@ mod tests { auth_manager, config, active_profile: None, + cli_kv_overrides: Vec::new(), + harness_overrides: ConfigOverrides::default(), + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, file_search, transcript_cells: Vec::new(), overlay: None, @@ -1970,8 +2472,12 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), - external_approval_routes: HashMap::new(), - paused_codex_events: VecDeque::new(), + thread_event_channels: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), }, rx, op_rx, @@ -2176,11 +2682,14 @@ mod tests { async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let user_cell = |text: &str| -> Arc { + let user_cell = |text: &str, + text_elements: Vec, + local_image_paths: Vec| + -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), + text_elements, + local_image_paths, }) as Arc }; let agent_cell = |text: &str| -> Arc { @@ -2203,33 +2712,38 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: PathBuf::new(), + rollout_path: Some(PathBuf::new()), }; Arc::new(new_session_info( app.chat_widget.config_ref(), app.chat_widget.current_model(), event, is_first, - app.chat_widget - .config_ref() - .features - .enabled(codex_core::features::Feature::CollaborationModes), - app.chat_widget.stored_collaboration_mode().clone(), )) as Arc }; + let placeholder = "[Image #1]"; + let edited_text = format!("follow-up (edited) {placeholder}"); + let edited_range = edited_text.len().saturating_sub(placeholder.len())..edited_text.len(); + let edited_text_elements = vec![TextElement::new(edited_range.into(), None)]; + let edited_local_image_paths = vec![PathBuf::from("/tmp/fake-image.png")]; + // Simulate a transcript with duplicated history (e.g., from prior backtracks) // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), - user_cell("first question"), + user_cell("first question", Vec::new(), Vec::new()), agent_cell("answer first"), - user_cell("follow-up"), + user_cell("follow-up", Vec::new(), Vec::new()), agent_cell("answer follow-up"), make_header(false), - user_cell("first question"), + user_cell("first question", Vec::new(), Vec::new()), agent_cell("answer first"), - user_cell("follow-up (edited)"), + user_cell( + &edited_text, + edited_text_elements.clone(), + edited_local_image_paths.clone(), + ), agent_cell("answer edited"), ]; @@ -2250,7 +2764,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: PathBuf::new(), + rollout_path: Some(PathBuf::new()), }), }); @@ -2262,7 +2776,9 @@ mod tests { .confirm_backtrack_from_main() .expect("backtrack selection"); assert_eq!(selection.nth_user_message, 1); - assert_eq!(selection.prefill, "follow-up (edited)"); + assert_eq!(selection.prefill, edited_text); + assert_eq!(selection.text_elements, edited_text_elements); + assert_eq!(selection.local_image_paths, edited_local_image_paths); app.apply_backtrack_rollback(selection); @@ -2293,7 +2809,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: PathBuf::new(), + rollout_path: Some(PathBuf::new()), }; app.chat_widget.handle_codex_event(Event { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 9a77b6bed7..256355dd05 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -24,6 +24,7 @@ //! both committed history and in-flight activity without changing flush or coalescing behavior. use std::any::TypeId; +use std::path::PathBuf; use std::sync::Arc; use crate::app::App; @@ -37,6 +38,7 @@ use codex_core::protocol::ErrorEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::ThreadId; +use codex_protocol::user_input::TextElement; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -78,6 +80,10 @@ pub(crate) struct BacktrackSelection { /// This is applied immediately on selection confirmation; if the rollback fails, the prefill /// remains as a convenience so the user can retry or edit. pub(crate) prefill: String, + /// Text elements associated with the selected user message. + pub(crate) text_elements: Vec, + /// Local image paths associated with the selected user message. + pub(crate) local_image_paths: Vec, } /// An in-flight rollback requested from core. @@ -198,16 +204,16 @@ impl App { } let prefill = selection.prefill.clone(); + let text_elements = selection.text_elements.clone(); + let local_image_paths = selection.local_image_paths.clone(); self.backtrack.pending_rollback = Some(PendingBacktrackRollback { selection, thread_id: self.chat_widget.thread_id(), }); self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); - if !prefill.is_empty() { - // TODO: Rehydrate text_elements/local_image_paths from the selected user cell so - // backtrack preserves image placeholders and attachments. + if !prefill.is_empty() || !text_elements.is_empty() || !local_image_paths.is_empty() { self.chat_widget - .set_composer_text(prefill, Vec::new(), Vec::new()); + .set_composer_text(prefill, text_elements, local_image_paths); } } @@ -481,15 +487,24 @@ impl App { return None; } - let prefill = nth_user_position(&self.transcript_cells, nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); + let (prefill, text_elements, local_image_paths) = + nth_user_position(&self.transcript_cells, nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|cell| { + ( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + ) + }) + .unwrap_or_else(|| (String::new(), Vec::new(), Vec::new())); Some(BacktrackSelection { nth_user_message, prefill, + text_elements, + local_image_paths, }) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b74899795c..9a2d979f1c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -23,7 +23,7 @@ use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -43,10 +43,10 @@ pub(crate) enum WindowsSandboxFallbackReason { #[derive(Debug)] pub(crate) enum AppEvent { CodexEvent(Event), - ExternalApprovalRequest { - thread_id: ThreadId, - event: Event, - }, + /// Open the agent picker for switching active threads. + OpenAgentPicker, + /// Switch the active thread to the selected agent. + SelectAgentThread(ThreadId), /// Start a new session. NewSession, @@ -103,8 +103,8 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), - /// Update the current collaboration mode in the running app and widget. - UpdateCollaborationMode(CollaborationMode), + /// Update the active collaboration mask in the running app and widget. + UpdateCollaborationMode(CollaborationModeMask), /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { @@ -240,10 +240,10 @@ pub(crate) enum AppEvent { /// Open the custom prompt option from the review popup. OpenReviewCustomPrompt, - /// Submit a user message with an explicit collaboration mode. + /// Submit a user message with an explicit collaboration mask. SubmitUserMessageWithMode { text: String, - collaboration_mode: CollaborationMode, + collaboration_mode: CollaborationModeMask, }, /// Open the approval popup. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 06c2808f2a..d1f8d691a2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -212,6 +212,7 @@ pub(crate) struct ChatComposer { pending_pastes: Vec<(String, String)>, large_paste_counters: HashMap, has_focus: bool, + /// Invariant: attached images are labeled `[Image #1]..[Image #N]` in vec order. attached_images: Vec, placeholder_text: String, is_task_running: bool, @@ -475,8 +476,9 @@ impl ChatComposer { /// Replace the composer content with text from an external editor. /// Clears pending paste placeholders and keeps only attachments whose - /// placeholder labels still appear in the new text. Cursor is placed at - /// the end after rebuilding elements. + /// placeholder labels still appear in the new text. Image placeholders + /// are renumbered to `[Image #1]..[Image #N]`. Cursor is placed at the end + /// after rebuilding elements. pub(crate) fn apply_external_edit(&mut self, text: String) { self.pending_pastes.clear(); @@ -538,6 +540,8 @@ impl ChatComposer { self.textarea.insert_str(&text[idx..]); } + // Keep image placeholders normalized to [Image #1].. in attachment order. + self.relabel_attached_images_and_update_placeholders(); self.textarea.set_cursor(self.textarea.text().len()); self.sync_popups(); } @@ -2035,15 +2039,6 @@ impl ChatComposer { { self.handle_paste(pasted); } - // Backspace at the start of an image placeholder should delete that placeholder (rather - // than deleting content before it). Do this without scanning the full text by consulting - // the textarea's element list. - if matches!(input.code, KeyCode::Backspace) - && self.try_remove_image_element_at_cursor_start() - { - return (InputResult::None, true); - } - // For non-char inputs (or after flushing), handle normally. // Track element removals so we can drop any corresponding placeholders without scanning // the full text. (Placeholders are atomic elements; when deleted, the element disappears.) @@ -2082,29 +2077,6 @@ impl ChatComposer { (InputResult::None, true) } - fn try_remove_image_element_at_cursor_start(&mut self) -> bool { - if self.attached_images.is_empty() { - return false; - } - - let p = self.textarea.cursor(); - let Some(payload) = self.textarea.element_payload_starting_at(p) else { - return false; - }; - let Some(idx) = self - .attached_images - .iter() - .position(|img| img.placeholder == payload) - else { - return false; - }; - - self.textarea.replace_range(p..p + payload.len(), ""); - self.attached_images.remove(idx); - self.relabel_attached_images_and_update_placeholders(); - true - } - fn reconcile_deleted_elements(&mut self, elements_before: Vec) { let elements_after: HashSet = self.textarea.element_payloads().into_iter().collect(); @@ -4658,8 +4630,7 @@ mod tests { assert!(!composer.textarea.text().contains(&placeholder)); assert!(composer.attached_images.is_empty()); - // Re-add and test backspace in middle: should break the placeholder string - // and drop the image mapping (same as text placeholder behavior). + // Re-add and ensure backspace at element start does not delete the placeholder. composer.attach_image(path); let placeholder2 = composer.attached_images[0].placeholder.clone(); // Move cursor to roughly middle of placeholder @@ -4667,8 +4638,8 @@ mod tests { let mid_pos = start_pos + (placeholder2.len() / 2); composer.textarea.set_cursor(mid_pos); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder2)); - assert!(composer.attached_images.is_empty()); + assert!(composer.textarea.text().contains(&placeholder2)); + assert_eq!(composer.attached_images.len(), 1); } else { panic!("Placeholder not found in textarea"); } @@ -4848,7 +4819,7 @@ mod tests { assert_eq!(composer.textarea.text(), "[Image #1][Image #2]"); assert_eq!(composer.attached_images.len(), 2); - // Delete the first element using normal textarea editing (Delete at cursor start). + // Delete the first element using normal textarea editing (forward Delete at cursor start). composer.textarea.set_cursor(0); composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); @@ -6081,7 +6052,7 @@ mod tests { false, ); - let placeholder = "[image 10x10]".to_string(); + let placeholder = local_image_label_text(1); composer.textarea.insert_element(&placeholder); composer.attached_images.push(AttachedImage { placeholder: placeholder.clone(), @@ -6115,7 +6086,7 @@ mod tests { false, ); - let placeholder = "[image 10x10]".to_string(); + let placeholder = local_image_label_text(1); composer.textarea.insert_element(&placeholder); composer.attached_images.push(AttachedImage { placeholder: placeholder.clone(), @@ -6165,7 +6136,7 @@ mod tests { false, ); - let placeholder = "[image 10x10]".to_string(); + let placeholder = local_image_label_text(1); composer.textarea.insert_element(&placeholder); composer.attached_images.push(AttachedImage { placeholder: placeholder.clone(), diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index ca83af9013..16f4f22a94 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -49,6 +49,7 @@ pub(crate) struct FooterProps { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CollaborationModeIndicator { Plan, + Code, PairProgramming, Execute, } @@ -64,6 +65,7 @@ impl CollaborationModeIndicator { }; match self { CollaborationModeIndicator::Plan => format!("Plan mode{suffix}"), + CollaborationModeIndicator::Code => format!("Code mode{suffix}"), CollaborationModeIndicator::PairProgramming => { format!("Pair Programming mode{suffix}") } @@ -75,6 +77,7 @@ impl CollaborationModeIndicator { let label = self.label(show_cycle_hint); match self { CollaborationModeIndicator::Plan => Span::from(label).magenta(), + CollaborationModeIndicator::Code => Span::from(label).cyan(), CollaborationModeIndicator::PairProgramming => Span::from(label).cyan(), CollaborationModeIndicator::Execute => Span::from(label).dim(), } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 3a849822b1..0235f7a9c1 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -766,12 +766,6 @@ impl TextArea { .collect() } - pub fn element_payload_starting_at(&self, pos: usize) -> Option { - let pos = pos.min(self.text.len()); - let elem = self.elements.iter().find(|e| e.range.start == pos)?; - self.text.get(elem.range.clone()).map(str::to_string) - } - /// Renames a single text element in-place, keeping it atomic. /// /// Use this when the element payload is an identifier (e.g. a placeholder) that must be @@ -1327,6 +1321,21 @@ mod tests { assert_eq!(t.text(), "b"); } + #[test] + fn delete_forward_deletes_element_at_left_edge() { + let mut t = TextArea::new(); + t.insert_str("a"); + t.insert_element(""); + t.insert_str("b"); + + let elem_start = t.elements[0].range.start; + t.set_cursor(elem_start); + t.delete_forward(1); + + assert_eq!(t.text(), "ab"); + assert_eq!(t.cursor(), elem_start); + } + #[test] fn delete_backward_word_and_kill_line_variants() { // delete backward word at end removes the whole previous word diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 93158c41cc..1252b792e3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -93,6 +93,7 @@ use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::local_image_label_text; @@ -122,7 +123,7 @@ const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan"; const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; -const PLAN_IMPLEMENTATION_EXECUTE_MESSAGE: &str = "Implement the plan."; +const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; use crate::app_event::AppEvent; use crate::app_event::ExitMode; @@ -178,6 +179,7 @@ use self::interrupts::InterruptManager; mod agent; use self::agent::spawn_agent; use self::agent::spawn_agent_from_existing; +pub(crate) use self::agent::spawn_op_forwarder; mod session_header; use self::session_header::SessionHeader; mod skills; @@ -414,12 +416,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - /// Stored collaboration mode with model and reasoning effort. + /// The unmasked collaboration mode settings (always Custom mode). /// - /// When collaboration modes feature is enabled, this is initialized to the first preset. - /// When disabled, this is Custom. The model and reasoning effort are stored here instead of - /// being read from config or current_model. - stored_collaboration_mode: CollaborationMode, + /// Masks are applied on top of this base mode to derive the effective mode. + current_collaboration_mode: CollaborationMode, + /// The currently active collaboration mask, if any. + active_collaboration_mask: Option, auth_manager: Arc, models_manager: Arc, otel_manager: OtelManager, @@ -726,27 +728,21 @@ impl ChatWidget { self.set_skills(None); self.thread_id = Some(event.session_id); self.forked_from = event.forked_from_id; - self.current_rollout_path = Some(event.rollout_path.clone()); + self.current_rollout_path = event.rollout_path.clone(); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); - // Only update stored collaboration settings when collaboration modes are disabled. - // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its - // instructions as-is; the session configured event should not override it. - if !self.collaboration_modes_enabled() { - self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( - Some(model_for_header.clone()), - Some(event.reasoning_effort), - None, - ); - } + self.current_collaboration_mode = self.current_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + self.refresh_model_display(); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, - self.collaboration_modes_enabled(), - self.stored_collaboration_mode.clone(), ); self.apply_session_info_cell(session_info_cell); @@ -911,7 +907,7 @@ impl ChatWidget { if !self.queued_user_messages.is_empty() { return; } - if !matches!(self.stored_collaboration_mode, CollaborationMode::Plan(_)) { + if self.active_mode_kind() != ModeKind::Plan { return; } let has_message = last_agent_message.is_some_and(|message| !message.trim().is_empty()); @@ -933,25 +929,25 @@ impl ChatWidget { } fn open_plan_implementation_prompt(&mut self) { - let execute_mode = collaboration_modes::execute_mode(self.models_manager.as_ref()); - let (implement_actions, implement_disabled_reason) = match execute_mode { - Some(collaboration_mode) => { - let user_text = PLAN_IMPLEMENTATION_EXECUTE_MESSAGE.to_string(); + let code_mask = collaboration_modes::code_mask(self.models_manager.as_ref()); + let (implement_actions, implement_disabled_reason) = match code_mask { + Some(mask) => { + let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string(); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::SubmitUserMessageWithMode { text: user_text.clone(), - collaboration_mode: collaboration_mode.clone(), + collaboration_mode: mask.clone(), }); })]; (actions, None) } - None => (Vec::new(), Some("Execute mode unavailable".to_string())), + None => (Vec::new(), Some("Code mode unavailable".to_string())), }; let items = vec![ SelectionItem { name: PLAN_IMPLEMENTATION_YES.to_string(), - description: Some("Switch to Execute and start coding.".to_string()), + description: Some("Switch to Code and start coding.".to_string()), selected_description: None, is_current: false, actions: implement_actions, @@ -1931,27 +1927,28 @@ impl ChatWidget { let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let model_override = model.as_deref(); + let model_for_header = model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or_else(|| model_for_header.clone()); let fallback_custom = Settings { - model: model_for_header.clone(), + model: header_model.clone(), reasoning_effort: None, developer_instructions: None, }; - let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { - initial_collaboration_mode( - models_manager.as_ref(), - fallback_custom, - config.experimental_mode, - ) - } else { - CollaborationMode::Custom(fallback_custom) + // Collaboration modes start in Custom mode (not activated). + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, }; - let active_cell = Some(Self::placeholder_session_header_cell( - &config, - config.features.enabled(Feature::CollaborationModes), - stored_collaboration_mode.clone(), - )); + let active_cell = Some(Self::placeholder_session_header_cell(&config)); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -1972,11 +1969,12 @@ impl ChatWidget { config, skills_all: Vec::new(), skills_initial_state: None, - stored_collaboration_mode, + current_collaboration_mode, + active_collaboration_mask, auth_manager, models_manager, otel_manager, - session_header: SessionHeader::new(model_for_header), + session_header: SessionHeader::new(header_model), initial_user_message, token_info: None, rate_limit_snapshot: None, @@ -2030,6 +2028,129 @@ impl ChatWidget { widget } + pub(crate) fn new_with_op_sender( + common: ChatWidgetInit, + codex_op_tx: UnboundedSender, + ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_user_message, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + is_first_run, + model, + otel_manager, + } = common; + let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; + config.model = model.clone(); + let mut rng = rand::rng(); + let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); + + let model_override = model.as_deref(); + let model_for_header = model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or_else(|| model_for_header.clone()); + let fallback_custom = Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }; + // Collaboration modes start in Custom mode (not activated). + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, + }; + + let active_cell = Some(Self::placeholder_session_header_cell(&config)); + + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_tx, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills: None, + }), + active_cell, + active_cell_revision: 0, + config, + skills_all: Vec::new(), + skills_initial_state: None, + current_collaboration_mode, + active_collaboration_mask, + auth_manager, + models_manager, + otel_manager, + session_header: SessionHeader::new(header_model), + initial_user_message, + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + unified_exec_wait_streak: None, + task_complete_pending: false, + unified_exec_processes: Vec::new(), + agent_turn_running: false, + mcp_startup_status: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + thread_id: None, + forked_from: None, + saw_plan_update_this_turn: false, + queued_user_messages: VecDeque::new(), + show_welcome_banner: is_first_run, + suppress_session_configured_redraw: false, + pending_notification: None, + quit_shortcut_expires_at: None, + quit_shortcut_key: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + had_work_activity: false, + last_separator_elapsed_secs: None, + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + external_editor_state: ExternalEditorState::Closed, + }; + + widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); + + widget + } + /// Create a ChatWidget attached to an existing conversation (e.g., a fork). pub(crate) fn new_from_existing( common: ChatWidgetInit, @@ -2053,7 +2174,16 @@ impl ChatWidget { let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - let header_model = model.unwrap_or_else(|| session_configured.model.clone()); + let model_override = model.as_deref(); + let header_model = model + .clone() + .unwrap_or_else(|| session_configured.model.clone()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or(header_model); let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); @@ -2063,14 +2193,10 @@ impl ChatWidget { reasoning_effort: None, developer_instructions: None, }; - let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { - initial_collaboration_mode( - models_manager.as_ref(), - fallback_custom, - config.experimental_mode, - ) - } else { - CollaborationMode::Custom(fallback_custom) + // Collaboration modes start in Custom mode (not activated). + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, }; let mut widget = Self { @@ -2092,7 +2218,8 @@ impl ChatWidget { config, skills_all: Vec::new(), skills_initial_state: None, - stored_collaboration_mode, + current_collaboration_mode, + active_collaboration_mask, auth_manager, models_manager, otel_manager, @@ -2316,6 +2443,11 @@ impl ChatWidget { self.bottom_pane.set_footer_hint_override(items); } + pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) { + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + pub(crate) fn can_launch_external_editor(&self) -> bool { self.bottom_pane.can_launch_external_editor() } @@ -2380,6 +2512,9 @@ impl ChatWidget { self.open_collaboration_modes_popup(); } } + SlashCommand::Agent => { + self.app_event_tx.send(AppEvent::OpenAgentPicker); + } SlashCommand::Approvals => { self.open_approvals_popup(); } @@ -2687,18 +2822,24 @@ impl ChatWidget { } } + let effective_mode = self.effective_collaboration_mode(); + let collaboration_mode = if self.collaboration_modes_enabled() { + self.active_collaboration_mask + .as_ref() + .map(|_| effective_mode.clone()) + } else { + None + }; let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model: self.stored_collaboration_mode.model().to_string(), - effort: self.stored_collaboration_mode.reasoning_effort(), + model: effective_mode.model().to_string(), + effort: effective_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode: self - .collaboration_modes_enabled() - .then(|| self.stored_collaboration_mode.clone()), + collaboration_mode, personality: None, }; @@ -2748,6 +2889,14 @@ impl ChatWidget { self.dispatch_event_msg(Some(id), msg, false); } + pub(crate) fn handle_codex_event_replay(&mut self, event: Event) { + let Event { msg, .. } = event; + if matches!(msg, EventMsg::ShutdownComplete) { + return; + } + self.dispatch_event_msg(None, msg, true); + } + /// Dispatch a protocol `EventMsg` to the appropriate handler. /// /// `id` is `Some` for live events and `None` for replayed events from @@ -3043,7 +3192,7 @@ impl ChatWidget { .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); let collaboration_mode = self.collaboration_mode_label(); - let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); + let reasoning_effort_override = Some(self.effective_reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -3412,7 +3561,7 @@ impl ChatWidget { } pub(crate) fn open_collaboration_modes_popup(&mut self) { - let presets = self.models_manager.list_collaboration_modes(); + let presets = collaboration_modes::presets_for_tui(self.models_manager.as_ref()); if presets.is_empty() { self.add_info_message( "No collaboration modes are available right now.".to_string(), @@ -3421,22 +3570,24 @@ impl ChatWidget { return; } + let current_kind = self + .active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .or_else(|| { + collaboration_modes::default_mask(self.models_manager.as_ref()) + .and_then(|mask| mask.mode) + }); let items: Vec = presets .into_iter() - .map(|preset| { - let name = match preset { - CollaborationMode::Plan(_) => "Plan", - CollaborationMode::PairProgramming(_) => "Pair Programming", - CollaborationMode::Execute(_) => "Execute", - CollaborationMode::Custom(_) => "Custom", - }; - let is_current = - collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + .map(|mask| { + let name = mask.name.clone(); + let is_current = current_kind == mask.mode; let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); })]; SelectionItem { - name: name.to_string(), + name, is_current, actions, dismiss_on_select: true, @@ -3552,7 +3703,7 @@ impl ChatWidget { let model_slug = preset.model.to_string(); let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.stored_collaboration_mode.reasoning_effort() + self.effective_reasoning_effort() } else { default_choice }; @@ -4410,23 +4561,15 @@ impl ChatWidget { } if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); - let settings = match &self.stored_collaboration_mode { - CollaborationMode::Plan(settings) - | CollaborationMode::PairProgramming(settings) - | CollaborationMode::Execute(settings) - | CollaborationMode::Custom(settings) => settings.clone(), - }; - let fallback_custom = settings.clone(); - self.stored_collaboration_mode = if enabled { - initial_collaboration_mode( - self.models_manager.as_ref(), - fallback_custom, - self.config.experimental_mode, - ) - } else { - CollaborationMode::Custom(settings) + let settings = self.current_collaboration_mode.settings.clone(); + self.current_collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings, }; + self.active_collaboration_mask = None; self.update_collaboration_mode_indicator(); + self.refresh_model_display(); + self.request_redraw(); } } @@ -4455,31 +4598,52 @@ impl ChatWidget { /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.stored_collaboration_mode = - self.stored_collaboration_mode + self.current_collaboration_mode = + self.current_collaboration_mode .with_updates(None, Some(effort), None); + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + { + mask.reasoning_effort = Some(effort); + } } /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { - self.session_header.set_model(model); - self.stored_collaboration_mode = - self.stored_collaboration_mode + self.current_collaboration_mode = + self.current_collaboration_mode .with_updates(Some(model.to_string()), None, None); + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + { + mask.model = Some(model.to_string()); + } + self.refresh_model_display(); } pub(crate) fn current_model(&self) -> &str { - self.stored_collaboration_mode.model() + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.model(); + } + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.as_deref()) + .unwrap_or_else(|| self.current_collaboration_mode.model()) } #[allow(dead_code)] // Used in tests - pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode { - &self.stored_collaboration_mode + pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode { + &self.current_collaboration_mode } #[cfg(test)] pub(crate) fn current_reasoning_effort(&self) -> Option { - self.stored_collaboration_mode.reasoning_effort() + self.effective_reasoning_effort() + } + + #[cfg(test)] + pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { + self.active_mode_kind() } fn is_session_configured(&self) -> bool { @@ -4490,6 +4654,55 @@ impl ChatWidget { self.config.features.enabled(Feature::CollaborationModes) } + fn initial_collaboration_mask( + config: &Config, + models_manager: &ModelsManager, + model_override: Option<&str>, + ) -> Option { + if !config.features.enabled(Feature::CollaborationModes) { + return None; + } + let kind = config.experimental_mode?; + let mut mask = collaboration_modes::mask_for_kind(models_manager, kind)?; + if let Some(model_override) = model_override { + mask.model = Some(model_override.to_string()); + } + Some(mask) + } + + fn active_mode_kind(&self) -> ModeKind { + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .unwrap_or(ModeKind::Custom) + } + + fn effective_reasoning_effort(&self) -> Option { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.reasoning_effort(); + } + let current_effort = self.current_collaboration_mode.reasoning_effort(); + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.reasoning_effort) + .unwrap_or(current_effort) + } + + fn effective_collaboration_mode(&self) -> CollaborationMode { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.clone(); + } + self.active_collaboration_mask.as_ref().map_or_else( + || self.current_collaboration_mode.clone(), + |mask| self.current_collaboration_mode.apply_mask(mask), + ) + } + + fn refresh_model_display(&mut self) { + let effective = self.effective_collaboration_mode(); + self.session_header.set_model(effective.model()); + } + fn model_display_name(&self) -> &str { let model = self.current_model(); if model.is_empty() { @@ -4504,11 +4717,12 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return None; } - match &self.stored_collaboration_mode { - CollaborationMode::Plan(_) => Some("Plan"), - CollaborationMode::PairProgramming(_) => Some("Pair Programming"), - CollaborationMode::Execute(_) => Some("Execute"), - CollaborationMode::Custom(_) => None, + match self.active_mode_kind() { + ModeKind::Plan => Some("Plan"), + ModeKind::Code => Some("Code"), + ModeKind::PairProgramming => Some("Pair Programming"), + ModeKind::Execute => Some("Execute"), + ModeKind::Custom => None, } } @@ -4516,13 +4730,12 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return None; } - match &self.stored_collaboration_mode { - CollaborationMode::Plan(_) => Some(CollaborationModeIndicator::Plan), - CollaborationMode::PairProgramming(_) => { - Some(CollaborationModeIndicator::PairProgramming) - } - CollaborationMode::Execute(_) => Some(CollaborationModeIndicator::Execute), - CollaborationMode::Custom(_) => None, + match self.active_mode_kind() { + ModeKind::Plan => Some(CollaborationModeIndicator::Plan), + ModeKind::Code => Some(CollaborationModeIndicator::Code), + ModeKind::PairProgramming => Some(CollaborationModeIndicator::PairProgramming), + ModeKind::Execute => Some(CollaborationModeIndicator::Execute), + ModeKind::Custom => None, } } @@ -4531,40 +4744,36 @@ impl ChatWidget { self.bottom_pane.set_collaboration_mode_indicator(indicator); } - /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). + /// Cycle to the next collaboration mode variant (Plan -> Code -> Plan). fn cycle_collaboration_mode(&mut self) { if !self.collaboration_modes_enabled() { return; } - if let Some(next_mode) = collaboration_modes::next_mode( + if let Some(next_mask) = collaboration_modes::next_mask( self.models_manager.as_ref(), - &self.stored_collaboration_mode, + self.active_collaboration_mask.as_ref(), ) { - self.set_collaboration_mode(next_mode); + self.set_collaboration_mask(next_mask); } } - /// Update the stored collaboration mode. + /// Update the active collaboration mask. /// - /// When collaboration modes are enabled, the current mode is attached to *every* - /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { + /// When collaboration modes are enabled and a preset is selected (not Custom), + /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mask(&mut self, mask: CollaborationModeMask) { if !self.collaboration_modes_enabled() { return; } - - self.stored_collaboration_mode = mode; + self.active_collaboration_mask = Some(mask); self.update_collaboration_mode_indicator(); + self.refresh_model_display(); self.request_redraw(); } /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell( - config: &Config, - is_collaboration: bool, - collaboration_mode: CollaborationMode, - ) -> Box { + fn placeholder_session_header_cell(config: &Config) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( DEFAULT_MODEL_DISPLAY_NAME.to_string(), @@ -4572,8 +4781,6 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, - is_collaboration, - collaboration_mode, )) } @@ -4744,11 +4951,9 @@ impl ChatWidget { pub(crate) fn submit_user_message_with_mode( &mut self, text: String, - collaboration_mode: CollaborationMode, + collaboration_mode: CollaborationModeMask, ) { - let model = collaboration_mode.model().to_string(); - self.set_collaboration_mode(collaboration_mode); - self.set_model(&model); + self.set_collaboration_mask(collaboration_mode); self.submit_user_message(text.into()); } @@ -5175,24 +5380,6 @@ fn extract_first_bold(s: &str) -> Option { None } -fn initial_collaboration_mode( - models_manager: &ModelsManager, - fallback_custom: Settings, - desired_mode: Option, -) -> CollaborationMode { - if let Some(kind) = desired_mode { - if kind == ModeKind::Custom { - return CollaborationMode::Custom(fallback_custom); - } - if let Some(mode) = collaboration_modes::mode_for_kind(models_manager, kind) { - return mode; - } - } - - collaboration_modes::default_mode(models_manager) - .unwrap_or(CollaborationMode::Custom(fallback_custom)) -} - async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { match BackendClient::from_auth(base_url, &auth) { Ok(client) => match client.get_rate_limits().await { diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index b24233c2af..c902e5c638 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -105,3 +105,18 @@ pub(crate) fn spawn_agent_from_existing( codex_op_tx } + +/// Spawn an op-forwarding loop for an existing thread without subscribing to events. +pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc) -> UnboundedSender { + let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); + + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + if let Err(e) = thread.submit(op).await { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + codex_op_tx +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap index da42eb07bb..1fe6fe9e88 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap @@ -4,7 +4,7 @@ expression: popup --- Implement this plan? -› 1. Yes, implement this plan Switch to Execute and start coding. +› 1. Yes, implement this plan Switch to Code and start coding. 2. No, stay in Plan mode Continue planning with the model. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap index d5ba66c1bc..4179616589 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap @@ -4,7 +4,7 @@ expression: popup --- Implement this plan? - 1. Yes, implement this plan Switch to Execute and start coding. + 1. Yes, implement this plan Switch to Code and start coding. › 2. No, stay in Plan mode Continue planning with the model. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1fb199b0d8..9eff1b2f41 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -64,6 +64,8 @@ use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -87,6 +89,7 @@ use tempfile::NamedTempFile; use tempfile::tempdir; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; +use toml::Value as TomlValue; #[cfg(target_os = "windows")] fn set_windows_sandbox_enabled(enabled: bool) { @@ -158,7 +161,7 @@ async fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; chat.handle_codex_event(Event { @@ -219,7 +222,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { text_elements: text_elements.clone(), local_images: local_images.clone(), })]), - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; chat.handle_codex_event(Event { @@ -266,7 +269,7 @@ async fn submission_preserves_text_elements_and_local_images() { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; chat.handle_codex_event(Event { id: "initial".into(), @@ -775,23 +778,16 @@ async fn make_chatwidget_manual( let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); - let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); let reasoning_effort = None; - let stored_collaboration_mode = if collaboration_modes_enabled { - collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { - CollaborationMode::Custom(Settings { - model: resolved_model.clone(), - reasoning_effort, - developer_instructions: None, - }) - }) - } else { - CollaborationMode::Custom(Settings { + let base_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { model: resolved_model.clone(), reasoning_effort, developer_instructions: None, - }) + }, }; + let current_collaboration_mode = base_mode; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -799,7 +795,8 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - stored_collaboration_mode, + current_collaboration_mode, + active_collaboration_mask: None, auth_manager, models_manager, otel_manager, @@ -1205,28 +1202,32 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() { else { panic!("expected SubmitUserMessageWithMode, got {event:?}"); }; - assert_eq!(text, PLAN_IMPLEMENTATION_EXECUTE_MESSAGE); - assert!(matches!(collaboration_mode, CollaborationMode::Execute(_))); + assert_eq!(text, PLAN_IMPLEMENTATION_CODING_MESSAGE); + assert_eq!(collaboration_mode.mode, Some(ModeKind::Code)); } #[tokio::test] -async fn submit_user_message_with_mode_sets_execute_collaboration_mode() { +async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - let execute_mode = collaboration_modes::execute_mode(chat.models_manager.as_ref()) - .expect("expected execute collaboration mode"); - chat.submit_user_message_with_mode("Implement the plan.".to_string(), execute_mode); + let code_mode = collaboration_modes::code_mask(chat.models_manager.as_ref()) + .expect("expected code collaboration mode"); + chat.submit_user_message_with_mode("Implement the plan.".to_string(), code_mode); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Execute(_)), + collaboration_mode: + Some(CollaborationMode { + mode: ModeKind::Code, + .. + }), personality: None, .. } => {} other => { - panic!("expected Op::UserTurn with execute collab mode, got {other:?}") + panic!("expected Op::UserTurn with code collab mode, got {other:?}") } } } @@ -1235,11 +1236,10 @@ async fn submit_user_message_with_mode_sets_execute_collaboration_mode() { async fn plan_implementation_popup_skips_replayed_turn_complete() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode::Plan(Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message: Some("Plan details".to_string()), @@ -1256,11 +1256,10 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { async fn plan_implementation_popup_skips_when_messages_queued() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode::Plan(Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); chat.bottom_pane.set_task_running(true); chat.queue_user_message("Queued message".into()); @@ -1277,11 +1276,10 @@ async fn plan_implementation_popup_skips_when_messages_queued() { async fn plan_implementation_popup_shows_on_plan_update_without_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode::Plan(Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); chat.on_task_started(); chat.on_plan_update(UpdatePlanArgs { @@ -1306,11 +1304,10 @@ async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode::Plan(Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); chat.on_task_started(); chat.on_plan_update(UpdatePlanArgs { @@ -2198,28 +2195,25 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.stored_collaboration_mode.clone(); + let initial = chat.current_collaboration_mode().clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode, initial); + assert_eq!(chat.current_collaboration_mode(), &initial); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Custom); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::Execute(_) - )); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_collaboration_mode(), &initial); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::Plan(_) - )); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Code); + assert_eq!(chat.current_collaboration_mode(), &initial); chat.on_task_started(); - let before = chat.stored_collaboration_mode.clone(); + let before = chat.active_collaboration_mode_kind(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode, before); + assert_eq!(chat.active_collaboration_mode_kind(), before); } #[tokio::test] @@ -2236,23 +2230,27 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let selected_mode = match rx.try_recv() { - Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + let selected_mask = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mask)) => mask, other => panic!("expected UpdateCollaborationMode event, got {other:?}"), }; - chat.set_collaboration_mode(selected_mode); + chat.set_collaboration_mask(selected_mask); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + collaboration_mode: + Some(CollaborationMode { + mode: ModeKind::Code, + .. + }), personality: None, .. } => {} other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + panic!("expected Op::UserTurn with code collab mode, got {other:?}") } } @@ -2261,18 +2259,96 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + collaboration_mode: + Some(CollaborationMode { + mode: ModeKind::Code, + .. + }), personality: None, .. } => {} other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + panic!("expected Op::UserTurn with code collab mode, got {other:?}") } } } #[tokio::test] -async fn collab_mode_defaults_to_pair_programming_when_enabled() { +async fn experimental_mode_plan_applies_on_startup() { + let codex_home = tempdir().expect("tempdir"); + let cfg = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![ + ( + "features.collaboration_modes".to_string(), + TomlValue::Boolean(true), + ), + ( + "tui.experimental_mode".to_string(), + TomlValue::String("plan".to_string()), + ), + ]) + .build() + .await + .expect("config"); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); + let thread_manager = Arc::new(ThreadManager::with_models_provider( + CodexAuth::from_api_key("test"), + cfg.model_provider.clone(), + )); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + let init = ChatWidgetInit { + config: cfg, + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(unbounded_channel::().0), + initial_user_message: None, + enhanced_keys_supported: false, + auth_manager, + models_manager: thread_manager.get_models_manager(), + feedback: codex_feedback::CodexFeedback::new(), + is_first_run: true, + model: Some(resolved_model.clone()), + otel_manager, + }; + + let chat = ChatWidget::new(init, thread_manager); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_model(), resolved_model); +} + +#[tokio::test] +async fn set_model_updates_active_collaboration_mask() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + + chat.set_model("gpt-5.1-codex-mini"); + + assert_eq!(chat.current_model(), "gpt-5.1-codex-mini"); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); +} + +#[tokio::test] +async fn set_reasoning_effort_updates_active_collaboration_mask() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + + chat.set_reasoning_effort(None); + + assert_eq!(chat.current_reasoning_effort(), None); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); +} + +#[tokio::test] +async fn collab_mode_is_not_sent_until_selected() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); @@ -2282,24 +2358,24 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + collaboration_mode, personality: None, .. - } => {} + } => { + assert_eq!(collaboration_mode, None); + } other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + panic!("expected Op::UserTurn, got {other:?}") } } } #[tokio::test] -async fn collab_mode_enabling_sets_pair_programming_default() { +async fn collab_mode_enabling_keeps_custom_until_selected() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::PairProgramming(_) - )); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Custom); + assert_eq!(chat.current_collaboration_mode().mode, ModeKind::Custom); } #[tokio::test] diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index 7059165552..95908d8e90 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -1,57 +1,61 @@ use codex_core::models_manager::manager::ModelsManager; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; -fn mode_kind(mode: &CollaborationMode) -> ModeKind { - match mode { - CollaborationMode::Plan(_) => ModeKind::Plan, - CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, - CollaborationMode::Execute(_) => ModeKind::Execute, - CollaborationMode::Custom(_) => ModeKind::Custom, - } +fn is_tui_mode(kind: ModeKind) -> bool { + matches!(kind, ModeKind::Plan | ModeKind::Code) } -pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { - let presets = models_manager.list_collaboration_modes(); +fn filtered_presets(models_manager: &ModelsManager) -> Vec { + models_manager + .list_collaboration_modes() + .into_iter() + .filter(|mask| mask.mode.is_some_and(is_tui_mode)) + .collect() +} + +pub(crate) fn presets_for_tui(models_manager: &ModelsManager) -> Vec { + filtered_presets(models_manager) +} + +pub(crate) fn default_mask(models_manager: &ModelsManager) -> Option { + let presets = filtered_presets(models_manager); presets .iter() - .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .find(|mask| mask.mode == Some(ModeKind::Code)) .cloned() .or_else(|| presets.into_iter().next()) } -pub(crate) fn mode_for_kind( +pub(crate) fn mask_for_kind( models_manager: &ModelsManager, kind: ModeKind, -) -> Option { - let presets = models_manager.list_collaboration_modes(); - presets.into_iter().find(|preset| mode_kind(preset) == kind) -} - -pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { - mode_kind(a) == mode_kind(b) +) -> Option { + if !is_tui_mode(kind) { + return None; + } + filtered_presets(models_manager) + .into_iter() + .find(|mask| mask.mode == Some(kind)) } /// Cycle to the next collaboration mode preset in list order. -pub(crate) fn next_mode( +pub(crate) fn next_mask( models_manager: &ModelsManager, - current: &CollaborationMode, -) -> Option { - let presets = models_manager.list_collaboration_modes(); + current: Option<&CollaborationModeMask>, +) -> Option { + let presets = filtered_presets(models_manager); if presets.is_empty() { return None; } - let current_kind = mode_kind(current); + let current_kind = current.and_then(|mask| mask.mode); let next_index = presets .iter() - .position(|preset| mode_kind(preset) == current_kind) + .position(|mask| mask.mode == current_kind) .map_or(0, |idx| (idx + 1) % presets.len()); presets.get(next_index).cloned() } -pub(crate) fn execute_mode(models_manager: &ModelsManager) -> Option { - models_manager - .list_collaboration_modes() - .into_iter() - .find(|preset| mode_kind(preset) == ModeKind::Execute) +pub(crate) fn code_mask(models_manager: &ModelsManager) -> Option { + mask_for_kind(models_manager, ModeKind::Code) } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 46d16a83f0..da2f6d5f2e 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -381,6 +381,19 @@ where Ok(()) } + /// Clear terminal scrollback (if supported) and force a full redraw. + pub fn clear_scrollback(&mut self) -> io::Result<()> { + if self.viewport_area.is_empty() { + return Ok(()); + } + self.backend + .set_cursor_position(self.viewport_area.as_position())?; + queue!(self.backend, Clear(crossterm::terminal::ClearType::Purge))?; + std::io::Write::flush(&mut self.backend)?; + self.previous_buffer_mut().reset(); + Ok(()) + } + /// Clears the inactive buffer and swaps it with the current buffer pub fn swap_buffers(&mut self) { self.previous_buffer_mut().reset(); diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs new file mode 100644 index 0000000000..2a9c016a1e --- /dev/null +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -0,0 +1,286 @@ +use std::path::Path; + +use crate::key_hint; +use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt as _; +use crate::selection_list::selection_option_row; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use color_eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::WidgetRef; +use tokio_stream::StreamExt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CwdPromptAction { + Resume, + Fork, +} + +impl CwdPromptAction { + fn verb(self) -> &'static str { + match self { + CwdPromptAction::Resume => "resume", + CwdPromptAction::Fork => "fork", + } + } + + fn past_participle(self) -> &'static str { + match self { + CwdPromptAction::Resume => "resumed", + CwdPromptAction::Fork => "forked", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CwdSelection { + Current, + Session, +} + +impl CwdSelection { + fn next(self) -> Self { + match self { + CwdSelection::Current => CwdSelection::Session, + CwdSelection::Session => CwdSelection::Current, + } + } + + fn prev(self) -> Self { + match self { + CwdSelection::Current => CwdSelection::Session, + CwdSelection::Session => CwdSelection::Current, + } + } +} + +pub(crate) async fn run_cwd_selection_prompt( + tui: &mut Tui, + action: CwdPromptAction, + current_cwd: &Path, + session_cwd: &Path, +) -> Result { + let mut screen = CwdPromptScreen::new( + tui.frame_requester(), + action, + current_cwd.display().to_string(), + session_cwd.display().to_string(), + ); + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + + let events = tui.event_stream(); + tokio::pin!(events); + + while !screen.is_done() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Paste(_) => {} + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + } + } + } else { + break; + } + } + + Ok(screen.selection().unwrap_or(CwdSelection::Session)) +} + +struct CwdPromptScreen { + request_frame: FrameRequester, + action: CwdPromptAction, + current_cwd: String, + session_cwd: String, + highlighted: CwdSelection, + selection: Option, +} + +impl CwdPromptScreen { + fn new( + request_frame: FrameRequester, + action: CwdPromptAction, + current_cwd: String, + session_cwd: String, + ) -> Self { + Self { + request_frame, + action, + current_cwd, + session_cwd, + highlighted: CwdSelection::Session, + selection: None, + } + } + + fn handle_key(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) + { + self.select(CwdSelection::Session); + return; + } + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()), + KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()), + KeyCode::Char('1') => self.select(CwdSelection::Session), + KeyCode::Char('2') => self.select(CwdSelection::Current), + KeyCode::Enter => self.select(self.highlighted), + KeyCode::Esc => self.select(CwdSelection::Session), + _ => {} + } + } + + fn set_highlight(&mut self, highlight: CwdSelection) { + if self.highlighted != highlight { + self.highlighted = highlight; + self.request_frame.schedule_frame(); + } + } + + fn select(&mut self, selection: CwdSelection) { + self.highlighted = selection; + self.selection = Some(selection); + self.request_frame.schedule_frame(); + } + + fn is_done(&self) -> bool { + self.selection.is_some() + } + + fn selection(&self) -> Option { + self.selection + } +} + +impl WidgetRef for &CwdPromptScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let mut column = ColumnRenderable::new(); + + let action_verb = self.action.verb(); + let action_past = self.action.past_participle(); + let current_cwd = self.current_cwd.as_str(); + let session_cwd = self.session_cwd.as_str(); + + column.push(""); + column.push(Line::from(vec![ + "Choose working directory to ".into(), + action_verb.bold(), + " this session".into(), + ])); + column.push(""); + column.push( + Line::from(format!( + "Session = latest cwd recorded in the {action_past} session" + )) + .dim() + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push( + Line::from("Current = your current working directory".dim()) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(""); + column.push(selection_option_row( + 0, + format!("Use session directory ({session_cwd})"), + self.highlighted == CwdSelection::Session, + )); + column.push(selection_option_row( + 1, + format!("Use current directory ({current_cwd})"), + self.highlighted == CwdSelection::Current, + )); + column.push(""); + column.push( + Line::from(vec![ + "Press ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " to continue".dim(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_backend::VT100Backend; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + + fn new_prompt() -> CwdPromptScreen { + CwdPromptScreen::new( + FrameRequester::test_dummy(), + CwdPromptAction::Resume, + "/Users/example/current".to_string(), + "/Users/example/session".to_string(), + ) + } + + #[test] + fn cwd_prompt_snapshot() { + let screen = new_prompt(); + let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render cwd prompt"); + insta::assert_snapshot!("cwd_prompt_modal", terminal.backend()); + } + + #[test] + fn cwd_prompt_fork_snapshot() { + let screen = CwdPromptScreen::new( + FrameRequester::test_dummy(), + CwdPromptAction::Fork, + "/Users/example/current".to_string(), + "/Users/example/session".to_string(), + ); + let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render cwd prompt"); + insta::assert_snapshot!("cwd_prompt_fork_modal", terminal.backend()); + } + + #[test] + fn cwd_prompt_selects_session_by_default() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(screen.selection(), Some(CwdSelection::Session)); + } + + #[test] + fn cwd_prompt_can_select_current() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(screen.selection(), Some(CwdSelection::Current)); + } +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8bd4eda851..174f2a7f6a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -19,7 +19,6 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; -use crate::key_hint; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; @@ -44,13 +43,11 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; -use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::user_input::TextElement; -use crossterm::event::KeyCode; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -906,8 +903,6 @@ pub(crate) fn new_session_info( requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, - is_collaboration: bool, - collaboration_mode: CollaborationMode, ) -> SessionInfoCell { let SessionConfiguredEvent { model, @@ -920,8 +915,6 @@ pub(crate) fn new_session_info( reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, - is_collaboration, - collaboration_mode, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -1003,8 +996,6 @@ pub(crate) struct SessionHeaderHistoryCell { model_style: Style, reasoning_effort: Option, directory: PathBuf, - is_collaboration: bool, - collaboration_mode: CollaborationMode, } impl SessionHeaderHistoryCell { @@ -1013,8 +1004,6 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, - is_collaboration: bool, - collaboration_mode: CollaborationMode, ) -> Self { Self::new_with_style( model, @@ -1022,8 +1011,6 @@ impl SessionHeaderHistoryCell { reasoning_effort, directory, version, - is_collaboration, - collaboration_mode, ) } @@ -1033,8 +1020,6 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, - is_collaboration: bool, - collaboration_mode: CollaborationMode, ) -> Self { Self { version, @@ -1042,20 +1027,6 @@ impl SessionHeaderHistoryCell { model_style, reasoning_effort, directory, - is_collaboration, - collaboration_mode, - } - } - - fn collaboration_mode_label(&self) -> Option<&'static str> { - if !self.is_collaboration { - return None; - } - match &self.collaboration_mode { - CollaborationMode::Plan(_) => Some("Plan"), - CollaborationMode::PairProgramming(_) => Some("Pair Programming"), - CollaborationMode::Execute(_) => Some("Execute"), - CollaborationMode::Custom(_) => None, } } @@ -1116,38 +1087,16 @@ impl HistoryCell for SessionHeaderHistoryCell { const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; - const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode"; const DIR_LABEL: &str = "directory:"; let label_width = DIR_LABEL.len(); - let model_spans: Vec> = if self.is_collaboration { - // Render collaboration mode instead of model - let collab_label = format!( - "{collab_label: = key_hint::shift(KeyCode::Tab).into(); - spans.push(shift_tab_span.cyan()); - spans.push(CHANGE_MODE_HINT_EXPLANATION.dim()); - spans - } else { - // Render model as before - let model_label = format!( - "{model_label:> = { let mut spans = vec![ Span::from(format!("{model_label} ")).dim(), Span::styled(self.model.clone(), self.model_style), @@ -1888,8 +1837,6 @@ mod tests { use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::protocol::McpAuthStatus; - use codex_protocol::config_types::CollaborationMode; - use codex_protocol::config_types::Settings; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; @@ -2346,12 +2293,6 @@ mod tests { Some(ReasoningEffortConfig::High), std::env::temp_dir(), "test", - false, - CollaborationMode::Custom(Settings { - model: "gpt-4o".to_string(), - reasoning_effort: Some(ReasoningEffortConfig::High), - developer_instructions: None, - }), ); let lines = render_lines(&cell.display_lines(80)); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cac1b12e06..0fac506d32 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -23,15 +23,23 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; +use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::format_config_error_with_source; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; +use codex_core::path_utils; use codex_core::protocol::AskForApproval; use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_utils_absolute_path::AbsolutePathBuf; +use cwd_prompt::CwdPromptAction; +use cwd_prompt::CwdSelection; use std::fs::OpenOptions; +use std::path::Path; use std::path::PathBuf; use tracing::error; use tracing_appender::non_blocking; @@ -52,6 +60,7 @@ mod collab; mod collaboration_modes; mod color; pub mod custom_terminal; +mod cwd_prompt; mod diff_render; mod exec_cell; mod exec_command; @@ -177,7 +186,18 @@ pub async fn run_main( { Ok(config_toml) => config_toml, Err(err) => { - eprintln!("Error loading config.toml: {err}"); + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } std::process::exit(1); } }; @@ -564,25 +584,27 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let current_cwd = config.cwd.clone(); + let allow_prompt = cli.cwd.is_none(); + let action_and_path_if_resume_or_fork = match &session_selection { + resume_picker::SessionSelection::Resume(path) => Some((CwdPromptAction::Resume, path)), + resume_picker::SessionSelection::Fork(path) => Some((CwdPromptAction::Fork, path)), + _ => None, + }; + let fallback_cwd = match action_and_path_if_resume_or_fork { + Some((action, path)) => { + resolve_cwd_for_resume_or_fork(&mut tui, ¤t_cwd, path, action, allow_prompt) + .await? + } + None => None, + }; + let config = match &session_selection { - resume_picker::SessionSelection::Resume(path) - | resume_picker::SessionSelection::Fork(path) => { - let history_cwd = match read_session_meta_line(path).await { - Ok(meta_line) => Some(meta_line.meta.cwd), - Err(err) => { - let rollout_path = path.display().to_string(); - tracing::warn!( - %rollout_path, - %err, - "Failed to read session metadata from rollout" - ); - None - } - }; + resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => { load_config_or_exit_with_fallback_cwd( cli_kv_overrides.clone(), overrides.clone(), - history_cwd, + fallback_cwd, ) .await } @@ -605,6 +627,8 @@ async fn run_ratatui_app( &mut tui, auth_manager, config, + cli_kv_overrides.clone(), + overrides.clone(), active_profile, prompt, images, @@ -622,6 +646,77 @@ async fn run_ratatui_app( app_result } +pub(crate) async fn read_session_cwd(path: &Path) -> Option { + // Prefer the latest TurnContext cwd so resume/fork reflects the most recent + // session directory (for the changed-cwd prompt). The alternative would be + // mutating the SessionMeta line when the session cwd changes, but the rollout + // is an append-only JSONL log and rewriting the head would be error-prone. + // When rollouts move to SQLite, we can drop this scan. + if let Some(cwd) = parse_latest_turn_context_cwd(path).await { + return Some(cwd); + } + match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + } +} + +async fn parse_latest_turn_context_cwd(path: &Path) -> Option { + let text = tokio::fs::read_to_string(path).await.ok()?; + for line in text.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(rollout_line) = serde_json::from_str::(trimmed) else { + continue; + }; + if let RolloutItem::TurnContext(item) = rollout_line.item { + return Some(item.cwd); + } + } + None +} + +pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool { + match ( + path_utils::normalize_for_path_comparison(current_cwd), + path_utils::normalize_for_path_comparison(session_cwd), + ) { + (Ok(current), Ok(session)) => current != session, + _ => current_cwd != session_cwd, + } +} + +pub(crate) async fn resolve_cwd_for_resume_or_fork( + tui: &mut Tui, + current_cwd: &Path, + path: &Path, + action: CwdPromptAction, + allow_prompt: bool, +) -> color_eyre::Result> { + let Some(history_cwd) = read_session_cwd(path).await else { + return Ok(None); + }; + if allow_prompt && cwds_differ(current_cwd, &history_cwd) { + let selection = + cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?; + return Ok(Some(match selection { + CwdSelection::Current => current_cwd.to_path_buf(), + CwdSelection::Session => history_cwd, + })); + } + Ok(Some(history_cwd)) +} + #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." @@ -759,7 +854,14 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool mod tests { use super::*; use codex_core::config::ConfigBuilder; + use codex_core::config::ConfigOverrides; use codex_core::config::ProjectConfig; + use codex_core::protocol::AskForApproval; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::TurnContextItem; use serial_test::serial; use tempfile::TempDir; @@ -833,4 +935,180 @@ mod tests { ); Ok(()) } + + fn build_turn_context(config: &Config, cwd: PathBuf) -> TurnContextItem { + let model = config + .model + .clone() + .unwrap_or_else(|| "gpt-5.1".to_string()); + TurnContextItem { + cwd, + approval_policy: config.approval_policy.value(), + sandbox_policy: config.sandbox_policy.get().clone(), + model, + personality: None, + collaboration_mode: None, + effort: config.model_reasoning_effort, + summary: config.model_reasoning_summary, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + } + } + + #[tokio::test] + async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let first = temp_dir.path().join("first"); + let second = temp_dir.path().join("second"); + std::fs::create_dir_all(&first)?; + std::fs::create_dir_all(&second)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + let lines = vec![ + RolloutLine { + timestamp: "t0".to_string(), + item: RolloutItem::TurnContext(build_turn_context(&config, first)), + }, + RolloutLine { + timestamp: "t1".to_string(), + item: RolloutItem::TurnContext(build_turn_context(&config, second.clone())), + }, + ]; + let mut text = String::new(); + for line in lines { + text.push_str(&serde_json::to_string(&line).expect("serialize rollout")); + text.push('\n'); + } + std::fs::write(&rollout_path, text)?; + + let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd"); + assert_eq!(cwd, second); + Ok(()) + } + + #[tokio::test] + async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()> + { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let current = temp_dir.path().join("current"); + let latest = temp_dir.path().join("latest"); + std::fs::create_dir_all(¤t)?; + std::fs::create_dir_all(&latest)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + let session_meta = SessionMeta { + cwd: current.clone(), + ..SessionMeta::default() + }; + let lines = vec![ + RolloutLine { + timestamp: "t0".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }, + RolloutLine { + timestamp: "t1".to_string(), + item: RolloutItem::TurnContext(build_turn_context(&config, latest.clone())), + }, + ]; + let mut text = String::new(); + for line in lines { + text.push_str(&serde_json::to_string(&line).expect("serialize rollout")); + text.push('\n'); + } + std::fs::write(&rollout_path, text)?; + + let session_cwd = read_session_cwd(&rollout_path).await.expect("expected cwd"); + assert_eq!(session_cwd, latest); + assert!(cwds_differ(¤t, &session_cwd)); + Ok(()) + } + + #[tokio::test] + async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().to_path_buf(); + let trusted = temp_dir.path().join("trusted"); + let untrusted = temp_dir.path().join("untrusted"); + std::fs::create_dir_all(&trusted)?; + std::fs::create_dir_all(&untrusted)?; + + // TOML keys need escaped backslashes on Windows paths. + let trusted_display = trusted.display().to_string().replace('\\', "\\\\"); + let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\"); + let config_toml = format!( + r#"[projects."{trusted_display}"] +trust_level = "trusted" + +[projects."{untrusted_display}"] +trust_level = "untrusted" +"# + ); + std::fs::write(temp_dir.path().join("config.toml"), config_toml)?; + + let trusted_overrides = ConfigOverrides { + cwd: Some(trusted.clone()), + ..Default::default() + }; + let trusted_config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .harness_overrides(trusted_overrides.clone()) + .build() + .await?; + assert_eq!( + trusted_config.approval_policy.value(), + AskForApproval::OnRequest + ); + + let untrusted_overrides = ConfigOverrides { + cwd: Some(untrusted), + ..trusted_overrides + }; + let untrusted_config = ConfigBuilder::default() + .codex_home(codex_home) + .harness_overrides(untrusted_overrides) + .build() + .await?; + assert_eq!( + untrusted_config.approval_policy.value(), + AskForApproval::UnlessTrusted + ); + Ok(()) + } + + #[tokio::test] + async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let _config = build_config(&temp_dir).await?; + let session_cwd = temp_dir.path().join("session"); + std::fs::create_dir_all(&session_cwd)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + let session_meta = SessionMeta { + cwd: session_cwd.clone(), + ..SessionMeta::default() + }; + let meta_line = RolloutLine { + timestamp: "t0".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + let text = format!( + "{}\n", + serde_json::to_string(&meta_line).expect("serialize meta") + ); + std::fs::write(&rollout_path, text)?; + + let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd"); + assert_eq!(cwd, session_cwd); + Ok(()) + } } diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 7e076ae160..78a7f0ce85 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -311,6 +311,9 @@ impl WidgetRef for &OnboardingScreen { } let scratch_area = Rect::new(0, 0, width, max_h); let mut scratch = Buffer::empty(scratch_area); + if let Step::Welcome(widget) = step { + widget.update_layout_area(scratch_area); + } step.render_ref(scratch_area, &mut scratch); let h = used_rows(&scratch, width, max_h).min(max_h); if h > 0 { diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index 8ff5e81982..bdf4f91408 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -11,6 +11,7 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use std::cell::Cell; use crate::ascii_animation::AsciiAnimation; use crate::onboarding::onboarding_screen::KeyboardHandler; @@ -19,13 +20,14 @@ use crate::tui::FrameRequester; use super::onboarding_screen::StepState; -const MIN_ANIMATION_HEIGHT: u16 = 20; +const MIN_ANIMATION_HEIGHT: u16 = 37; const MIN_ANIMATION_WIDTH: u16 = 60; pub(crate) struct WelcomeWidget { pub is_logged_in: bool, animation: AsciiAnimation, animations_enabled: bool, + layout_area: Cell>, } impl KeyboardHandler for WelcomeWidget { @@ -53,8 +55,13 @@ impl WelcomeWidget { is_logged_in, animation: AsciiAnimation::new(request_frame), animations_enabled, + layout_area: Cell::new(None), } } + + pub(crate) fn update_layout_area(&self, area: Rect) { + self.layout_area.set(Some(area)); + } } impl WidgetRef for &WelcomeWidget { @@ -64,12 +71,14 @@ impl WidgetRef for &WelcomeWidget { self.animation.schedule_next_frame(); } + let layout_area = self.layout_area.get().unwrap_or(area); // Skip the animation entirely when the viewport is too small so we don't clip frames. - let show_animation = - area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH; + let show_animation = self.animations_enabled + && layout_area.height >= MIN_ANIMATION_HEIGHT + && layout_area.width >= MIN_ANIMATION_WIDTH; let mut lines: Vec = Vec::new(); - if show_animation && self.animations_enabled { + if show_animation { let frame = self.animation.current_frame(); lines.extend(frame.lines().map(Into::into)); lines.push("".into()); @@ -99,6 +108,7 @@ impl StepStateProvider for WelcomeWidget { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -106,31 +116,37 @@ mod tests { static VARIANT_B: [&str; 1] = ["frame-b"]; static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B]; + fn row_containing(buf: &Buffer, needle: &str) -> Option { + (0..buf.area.height).find(|&y| { + let mut row = String::new(); + for x in 0..buf.area.width { + row.push_str(buf[(x, y)].symbol()); + } + row.contains(needle) + }) + } + #[test] fn welcome_renders_animation_on_first_draw() { let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true); let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT); let mut buf = Buffer::empty(area); + let frame_lines = widget.animation.current_frame().lines().count() as u16; (&widget).render(area, &mut buf); - let mut found = false; - let mut last_non_empty: Option = None; - for y in 0..area.height { - for x in 0..area.width { - if !buf[(x, y)].symbol().trim().is_empty() { - found = true; - last_non_empty = Some(y); - break; - } - } - } + let welcome_row = row_containing(&buf, "Welcome"); + assert_eq!(welcome_row, Some(frame_lines + 1)); + } - assert!(found, "expected welcome animation to render characters"); - let measured_rows = last_non_empty.map(|v| v + 2).unwrap_or(0); - assert!( - measured_rows >= MIN_ANIMATION_HEIGHT, - "expected measurement to report at least {MIN_ANIMATION_HEIGHT} rows, got {measured_rows}" - ); + #[test] + fn welcome_skips_animation_below_height_breakpoint() { + let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true); + let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1); + let mut buf = Buffer::empty(area); + (&widget).render(area, &mut buf); + + let welcome_row = row_containing(&buf, "Welcome"); + assert_eq!(welcome_row, Some(0)); } #[test] @@ -139,6 +155,7 @@ mod tests { is_logged_in: false, animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0), animations_enabled: true, + layout_area: Cell::new(None), }; let before = widget.animation.current_frame(); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index f825f32fa8..2562d5e689 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -26,6 +26,7 @@ pub enum SlashCommand { Init, Compact, Collab, + Agent, // Undo, Diff, Mention, @@ -60,6 +61,7 @@ impl SlashCommand { SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Collab => "change collaboration mode (experimental)", + SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", @@ -105,6 +107,7 @@ impl SlashCommand { SlashCommand::Rollout => true, SlashCommand::TestApproval => true, SlashCommand::Collab => true, + SlashCommand::Agent => true, } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap new file mode 100644 index 0000000000..38b712e872 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/cwd_prompt.rs +expression: terminal.backend() +--- + +Choose working directory to fork this session + + Session = latest cwd recorded in the forked session + Current = your current working directory + +› 1. Use session directory (/Users/example/session) + 2. Use current directory (/Users/example/current) + + Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap new file mode 100644 index 0000000000..4e87cfdfc7 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/cwd_prompt.rs +expression: terminal.backend() +--- + +Choose working directory to resume this session + + Session = latest cwd recorded in the resumed session + Current = your current working directory + +› 1. Use session directory (/Users/example/session) + 2. Use current directory (/Users/example/current) + + Press enter to continue diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index b5cf56a6b4..2e1e329a1f 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -7,6 +7,8 @@ pub enum UpdateAction { BunGlobalLatest, /// Update via `brew upgrade codex`. BrewUpgrade, + /// Update via the curl installer. + CurlInstallerUpdate, } impl UpdateAction { @@ -16,6 +18,13 @@ impl UpdateAction { UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + UpdateAction::CurlInstallerUpdate => ( + "bash", + &[ + "-c", + "curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/update.sh | bash", + ], + ), } } @@ -32,12 +41,14 @@ pub(crate) fn get_update_action() -> Option { let exe = std::env::current_exe().unwrap_or_default(); let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + let managed_by_curl = std::env::var_os("CODEX_MANAGED_BY_CURL").is_some(); detect_update_action( cfg!(target_os = "macos"), &exe, managed_by_npm, managed_by_bun, + managed_by_curl, ) } @@ -47,11 +58,14 @@ fn detect_update_action( current_exe: &std::path::Path, managed_by_npm: bool, managed_by_bun: bool, + managed_by_curl: bool, ) -> Option { if managed_by_npm { Some(UpdateAction::NpmGlobalLatest) } else if managed_by_bun { Some(UpdateAction::BunGlobalLatest) + } else if managed_by_curl { + Some(UpdateAction::CurlInstallerUpdate) } else if is_macos && (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local")) { @@ -68,15 +82,21 @@ mod tests { #[test] fn detects_update_action_without_env_mutation() { assert_eq!( - detect_update_action(false, std::path::Path::new("/any/path"), false, false), + detect_update_action( + false, + std::path::Path::new("/any/path"), + false, + false, + false + ), None ); assert_eq!( - detect_update_action(false, std::path::Path::new("/any/path"), true, false), + detect_update_action(false, std::path::Path::new("/any/path"), true, false, false), Some(UpdateAction::NpmGlobalLatest) ); assert_eq!( - detect_update_action(false, std::path::Path::new("/any/path"), false, true), + detect_update_action(false, std::path::Path::new("/any/path"), false, true, false), Some(UpdateAction::BunGlobalLatest) ); assert_eq!( @@ -84,7 +104,8 @@ mod tests { true, std::path::Path::new("/opt/homebrew/bin/codex"), false, - false + false, + false, ), Some(UpdateAction::BrewUpgrade) ); @@ -93,9 +114,14 @@ mod tests { true, std::path::Path::new("/usr/local/bin/codex"), false, - false + false, + false, ), Some(UpdateAction::BrewUpgrade) ); + assert_eq!( + detect_update_action(false, std::path::Path::new("/any/path"), false, false, true), + Some(UpdateAction::CurlInstallerUpdate) + ); } } diff --git a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs index 1739399901..a016c00c75 100644 --- a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs @@ -16,6 +16,7 @@ use codex_windows_sandbox::SandboxPolicy; use serde::Deserialize; use std::collections::HashMap; use std::ffi::c_void; +use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; @@ -78,13 +79,20 @@ unsafe fn create_job_kill_on_close() -> Result { Ok(h) } +fn read_request_file(req_path: &Path) -> Result { + let content = std::fs::read_to_string(req_path) + .with_context(|| format!("read request file {}", req_path.display())); + let _ = std::fs::remove_file(req_path); + content +} + pub fn main() -> Result<()> { let mut input = String::new(); let mut args = std::env::args().skip(1); if let Some(first) = args.next() { if let Some(rest) = first.strip_prefix("--request-file=") { let req_path = PathBuf::from(rest); - input = std::fs::read_to_string(&req_path).context("read request file")?; + input = read_request_file(&req_path)?; } } if input.is_empty() { @@ -265,3 +273,21 @@ pub fn main() -> Result<()> { } std::process::exit(exit_code); } + +#[cfg(test)] +mod tests { + use super::read_request_file; + use pretty_assertions::assert_eq; + use std::fs; + + #[test] + fn removes_request_file_after_read() { + let dir = tempfile::tempdir().expect("tempdir"); + let req_path = dir.path().join("request.json"); + fs::write(&req_path, "{\"ok\":true}").expect("write request"); + + let content = read_request_file(&req_path).expect("read request"); + assert_eq!(content, "{\"ok\":true}"); + assert!(!req_path.exists(), "request file should be removed"); + } +} diff --git a/defs.bzl b/defs.bzl index 5fab68f436..249c3a4583 100644 --- a/defs.bzl +++ b/defs.bzl @@ -89,8 +89,6 @@ def codex_rust_crate( binaries = DEP_DATA.get(native.package_name())["binaries"] - # TODO(zbarsky): cargo_build_script support? - lib_srcs = crate_srcs or native.glob(["src/**/*.rs"], exclude = binaries.values(), allow_empty = True) if native.glob(["build.rs"], allow_empty = True): diff --git a/docs/install.md b/docs/install.md index 20d8b54a54..d0306ccbf3 100644 --- a/docs/install.md +++ b/docs/install.md @@ -8,6 +8,15 @@ | Git (optional, recommended) | 2.23+ for built-in PR helpers | | RAM | 4-GB minimum (8-GB recommended) | +### Install via curl + +```bash +curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash +``` + +The curl installer writes under `CODEX_HOME` (default: `~/.codex`). See +`installer/README.md` for the detailed layout and mechanics. + ### DotSlash The GitHub Release also contains a [DotSlash](https://dotslash-cli.com/) file for the Codex CLI named `codex`. Using a DotSlash file makes it possible to make a lightweight commit to source control to ensure all contributors use the same version of an executable, regardless of what platform they use for development. diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000000..34ac29a040 --- /dev/null +++ b/installer/README.md @@ -0,0 +1,104 @@ +# Codex Curl Installer + +This folder contains the non-Rust assets for the curl-based Codex installer +and update flow. Rust code lives under `codex-rs/`. + +## Goals + +- Download only the binaries needed for the current platform. +- Keep the install isolated under `CODEX_HOME` (default: `~/.codex`). +- Avoid breaking npm/brew installs; shadowing is acceptable. +- Support additional helper CLIs without globally polluting `PATH`. + +## Install Root (`CODEX_HOME`) + +The installer treats `CODEX_HOME` as the install root: + +- If `CODEX_HOME` is set, it is used as-is. +- Otherwise, the default is `~/.codex`. + +All curl-managed artifacts should live under this root. + +## On-Disk Layout + +The layout is designed to support multiple versions, helper binaries, and +atomic updates: + +- `CODEX_HOME/bin/` +- `CODEX_HOME/versions//` +- `CODEX_HOME/versions//bin/` +- `CODEX_HOME/tools///` +- `CODEX_HOME/tools/bin/` + +Key conventions: + +- The user-facing entrypoint is `CODEX_HOME/bin/codex`. +- `CODEX_HOME/versions/current` is a symlink to the active version directory. +- Helper CLIs that ship with Codex (for example, Windows sandbox helpers) live + in `CODEX_HOME/versions//bin/`. +- Third-party tools we fetch (for example, `rg`) live under + `CODEX_HOME/tools/...`, with optional shims in `CODEX_HOME/tools/bin/`. + +## PATH Strategy + +We separate the user's global `PATH` from Codex's runtime `PATH`: + +1. The installer ensures `CODEX_HOME/bin` is on the user's `PATH`. +2. The `codex` wrapper augments `PATH` at runtime to include: + - `CODEX_HOME/bin` + - `CODEX_HOME/tools/bin` + - `CODEX_HOME/versions/current/bin` + +This keeps helper CLIs available to Codex without exposing them as global +commands in every shell session. + +## Versioning And Atomic Updates + +Curl-managed installs should be versioned: + +1. Download into a versioned directory: + - `CODEX_HOME/versions//` +2. Link `CODEX_HOME/versions/current` to the new version atomically. +3. Keep a small number of prior versions for rollback. + +Because the wrapper resolves through `versions/current`, repointing the +symlink updates the effective version without editing shell rc files again. + +## Helper CLI Placement + +Any additional CLIs that Codex needs at runtime should follow these rules: + +- Bundled CLIs that are version-coupled to Codex: + - Place in `CODEX_HOME/versions//bin/` +- Third-party tools that may be shared across versions: + - Place in `CODEX_HOME/tools///` + - Optionally add a stable shim in `CODEX_HOME/tools/bin/` + +The wrapper then makes them available during execution. + +## Ripgrep (`rg`) + +The preferred approach is: + +- Use a system `rg` when available. +- Otherwise, allow curl-managed installs to place `rg` under + `CODEX_HOME/tools/rg//` with a shim in `CODEX_HOME/tools/bin/rg`. + +Codex CLI can optionally honor an explicit `CODEX_RG_PATH` to point directly +to a managed `rg`. + +## Scripts + +Planned/expected scripts in this folder: + +- `installer/install.sh` +- `installer/lib.sh` +- `installer/update.sh` + +The public one-liner should look like: + +```sh +curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash +``` + +All scripts must honor `CODEX_HOME` with a fallback to `~/.codex`. diff --git a/installer/install.sh b/installer/install.sh new file mode 100755 index 0000000000..7005392c07 --- /dev/null +++ b/installer/install.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +if [ -z "${BASH_VERSION:-}" ]; then + echo "This installer requires bash." >&2 + echo "Re-run with: curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash" >&2 + exit 1 +fi + +set -euo pipefail + +INSTALLER_BASE_URL="${CODEX_INSTALLER_BASE_URL:-https://raw.githubusercontent.com/openai/codex/main/installer}" + +load_lib() { + local script_dir lib_path tmp_lib + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + lib_path="${script_dir}/lib.sh" + + if [ -f "$lib_path" ]; then + # Running from a repo checkout. + # shellcheck disable=SC1090 + source "$lib_path" + return + fi + + tmp_lib="$(mktemp)" + curl -fsSL "${INSTALLER_BASE_URL}/lib.sh" -o "$tmp_lib" + # shellcheck disable=SC1090 + source "$tmp_lib" + rm -f "$tmp_lib" +} + +load_lib + +main() { + ensure_dirs + + local arch os tag version url tarball rc_file resolved_home + arch="$(detect_arch)" + os="$(detect_os)" + + if [ -n "${CODEX_VERSION:-}" ]; then + version="$(normalize_version "$CODEX_VERSION")" + tag="$(release_tag_for_version "$version")" + else + tag="$(latest_tag)" + if [ -z "$tag" ]; then + echo "Failed to determine the latest Codex release tag." >&2 + exit 1 + fi + version="$(normalize_version "$tag")" + fi + + url="$(release_url "$version" "$arch" "$os")" + tarball="$(mktemp)" + curl -fsSL "$url" -o "$tarball" + + install_version_from_tarball "$version" "$tarball" "$arch" "$os" + activate_version "$version" + install_wrapper + cleanup_old_versions 2 + + rc_file="$(choose_rc_file)" + resolved_home="$(codex_home)" + ensure_path_block "$rc_file" "$resolved_home" + + rm -f "$tarball" + + echo "Codex ${version} installed to ${resolved_home}" + echo "Updated PATH in ${rc_file}" + echo "Open a new shell or run: source ${rc_file}" +} + +main "$@" diff --git a/installer/lib.sh b/installer/lib.sh new file mode 100755 index 0000000000..d55682c05f --- /dev/null +++ b/installer/lib.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash + +if [ -z "${BASH_VERSION:-}" ]; then + echo "Codex installer requires bash." >&2 + exit 1 +fi + +set -euo pipefail + +CODEX_HOME_DEFAULT="${HOME}/.codex" +CODEX_HOME_RESOLVED="${CODEX_HOME:-$CODEX_HOME_DEFAULT}" + +codex_home() { + printf '%s\n' "$CODEX_HOME_RESOLVED" +} + +codex_bin_dir() { + printf '%s/bin\n' "$(codex_home)" +} + +codex_versions_dir() { + printf '%s/versions\n' "$(codex_home)" +} + +codex_tools_dir() { + printf '%s/tools\n' "$(codex_home)" +} + +codex_tools_bin_dir() { + printf '%s/bin\n' "$(codex_tools_dir)" +} + +current_version_link() { + printf '%s/current\n' "$(codex_versions_dir)" +} + +detect_os() { + local uname_s + uname_s="$(uname -s)" + case "$uname_s" in + Darwin) printf '%s\n' "apple-darwin" ;; + Linux) printf '%s\n' "unknown-linux-musl" ;; + *) + echo "Unsupported OS: $uname_s" >&2 + exit 1 + ;; + esac +} + +detect_arch() { + local uname_m + uname_m="$(uname -m)" + case "$uname_m" in + arm64|aarch64) printf '%s\n' "aarch64" ;; + x86_64|amd64) printf '%s\n' "x86_64" ;; + *) + echo "Unsupported architecture: $uname_m" >&2 + exit 1 + ;; + esac +} + +github_api() { + local path="$1" + curl -fsSL \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/openai/codex/${path}" +} + +latest_tag() { + github_api "releases/latest" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 +} + +normalize_version() { + local tag="$1" + tag="${tag#rust-v}" + tag="${tag#v}" + printf '%s\n' "$tag" +} + +release_tag_for_version() { + local version="$1" + printf '%s\n' "rust-v${version}" +} + +expected_binary_name() { + local arch="$1" + local os="$2" + printf '%s\n' "codex-${arch}-${os}" +} + +release_url() { + local version="$1" + local arch="$2" + local os="$3" + local tag + tag="$(release_tag_for_version "$version")" + printf '%s\n' "https://github.com/openai/codex/releases/download/${tag}/codex-${arch}-${os}.tar.gz" +} + +ensure_dirs() { + mkdir -p "$(codex_bin_dir)" "$(codex_versions_dir)" "$(codex_tools_bin_dir)" +} + +ensure_path_block() { + local rc_file="$1" + local resolved_home="$2" + local start_marker="# >>> codex >>>" + + if [ ! -f "$rc_file" ]; then + touch "$rc_file" + fi + + if grep -Fq "$start_marker" "$rc_file"; then + return + fi + + if [ "$resolved_home" = "${HOME}/.codex" ]; then + cat >>"$rc_file" <<'EOF' +# >>> codex >>> +export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +export PATH="$CODEX_HOME/bin:$PATH" +# <<< codex <<< +EOF + else + cat >>"$rc_file" <>> codex >>> +export CODEX_HOME="${resolved_home}" +export PATH="\$CODEX_HOME/bin:\$PATH" +# <<< codex <<< +EOF + fi +} + +choose_rc_file() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + case "$shell_name" in + zsh) printf '%s\n' "${HOME}/.zshrc" ;; + bash) + if [ -f "${HOME}/.bashrc" ]; then + printf '%s\n' "${HOME}/.bashrc" + else + printf '%s\n' "${HOME}/.bash_profile" + fi + ;; + *) + printf '%s\n' "${HOME}/.profile" + ;; + esac +} + +install_wrapper() { + local wrapper_path + wrapper_path="$(codex_bin_dir)/codex" + + cat >"$wrapper_path" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +CURRENT_LINK="$CODEX_HOME/versions/current" +TARGET="$CURRENT_LINK/bin/codex" + +if [ ! -x "$TARGET" ]; then + echo "Codex is not installed under $CODEX_HOME." >&2 + echo "Run the installer again to repair the installation." >&2 + exit 1 +fi + +export CODEX_MANAGED_BY_CURL=1 +export PATH="$CODEX_HOME/bin:$CODEX_HOME/tools/bin:$CURRENT_LINK/bin:$PATH" + +exec "$TARGET" "$@" +EOF + + chmod +x "$wrapper_path" +} + +install_version_from_tarball() { + local version="$1" + local tarball="$2" + local arch="$3" + local os="$4" + + local versions_dir version_dir bin_dir expected_name tmpdir main_candidate + versions_dir="$(codex_versions_dir)" + version_dir="${versions_dir}/${version}" + bin_dir="${version_dir}/bin" + expected_name="$(expected_binary_name "$arch" "$os")" + + rm -rf "$version_dir" + mkdir -p "$bin_dir" + + tmpdir="$(mktemp -d)" + tar -xzf "$tarball" -C "$tmpdir" + + if [ -f "$tmpdir/$expected_name" ]; then + main_candidate="$tmpdir/$expected_name" + elif [ -f "$tmpdir/codex" ]; then + main_candidate="$tmpdir/codex" + else + main_candidate="$(find "$tmpdir" -type f -name 'codex*' | head -n1 || true)" + fi + if [ -z "$main_candidate" ]; then + echo "Failed to locate codex binary in tarball." >&2 + exit 1 + fi + + local files file base dest + mapfile -t files < <(find "$tmpdir" -type f) + for file in "${files[@]}"; do + base="$(basename "$file")" + if [ "$file" = "$main_candidate" ]; then + dest="$bin_dir/codex" + else + dest="$bin_dir/$base" + fi + mv "$file" "$dest" + chmod +x "$dest" 2>/dev/null || true + done + + if [ ! -x "$bin_dir/codex" ]; then + echo "Failed to install codex binary." >&2 + exit 1 + fi + + rm -rf "$tmpdir" +} + +activate_version() { + local version="$1" + local link + link="$(current_version_link)" + ln -sfn "$(codex_versions_dir)/$version" "$link" +} + +cleanup_old_versions() { + local keep="${1:-2}" + local versions_dir + versions_dir="$(codex_versions_dir)" + if [ ! -d "$versions_dir" ]; then + return + fi + if ! compgen -G "$versions_dir/*" >/dev/null; then + return + fi + + # Remove older versions while keeping the most recent N directories. + # We intentionally ignore errors here so cleanup never blocks install. + ls -1dt "$versions_dir"/* 2>/dev/null \ + | grep -v '/current$' \ + | tail -n +"$((keep + 1))" \ + | xargs -I{} rm -rf "{}" 2>/dev/null || true +} diff --git a/installer/update.sh b/installer/update.sh new file mode 100755 index 0000000000..3cdee7e81f --- /dev/null +++ b/installer/update.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +if [ -z "${BASH_VERSION:-}" ]; then + echo "This updater requires bash." >&2 + echo "Re-run with: curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/update.sh | bash" >&2 + exit 1 +fi + +set -euo pipefail + +INSTALLER_BASE_URL="${CODEX_INSTALLER_BASE_URL:-https://raw.githubusercontent.com/openai/codex/main/installer}" + +load_lib() { + local script_dir lib_path tmp_lib + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + lib_path="${script_dir}/lib.sh" + + if [ -f "$lib_path" ]; then + # Running from a repo checkout. + # shellcheck disable=SC1090 + source "$lib_path" + return + fi + + tmp_lib="$(mktemp)" + curl -fsSL "${INSTALLER_BASE_URL}/lib.sh" -o "$tmp_lib" + # shellcheck disable=SC1090 + source "$tmp_lib" + rm -f "$tmp_lib" +} + +load_lib + +main() { + ensure_dirs + + local arch os tag version url tarball rc_file resolved_home + arch="$(detect_arch)" + os="$(detect_os)" + + tag="$(latest_tag)" + if [ -z "$tag" ]; then + echo "Failed to determine the latest Codex release tag." >&2 + exit 1 + fi + version="$(normalize_version "$tag")" + + url="$(release_url "$version" "$arch" "$os")" + tarball="$(mktemp)" + curl -fsSL "$url" -o "$tarball" + + install_version_from_tarball "$version" "$tarball" "$arch" "$os" + activate_version "$version" + install_wrapper + cleanup_old_versions 2 + + rc_file="$(choose_rc_file)" + resolved_home="$(codex_home)" + ensure_path_block "$rc_file" "$resolved_home" + + rm -f "$tarball" + + echo "Codex updated to ${version}" +} + +main "$@"