Merge 8400b3bd97 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-01-25 10:29:45 -08:00
committed by GitHub
120 changed files with 9842 additions and 1135 deletions
+163
View File
@@ -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}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
args=()
skip_next=0
for arg in "\$@"; do
if [[ "\${skip_next}" -eq 1 ]]; then
skip_next=0
continue
fi
case "\${arg}" in
--target)
skip_next=1
continue
;;
--target=*|-target=*|-target)
# Drop any explicit --target/-target flags. Zig expects -target and
# rejects Rust triples like *-unknown-linux-musl.
if [[ "\${arg}" == "-target" ]]; then
skip_next=1
fi
continue
;;
esac
args+=("\${arg}")
done
exec "${zig_bin}" cc -target "${zig_target}" "\${args[@]}"
EOF
cat >"${cxx}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
args=()
skip_next=0
for arg in "\$@"; do
if [[ "\${skip_next}" -eq 1 ]]; then
skip_next=0
continue
fi
case "\${arg}" in
--target)
skip_next=1
continue
;;
--target=*|-target=*|-target)
if [[ "\${arg}" == "-target" ]]; then
skip_next=1
fi
continue
;;
esac
args+=("\${arg}")
done
exec "${zig_bin}" c++ -target "${zig_target}" "\${args[@]}"
EOF
chmod +x "${cc}" "${cxx}"
sysroot="$("${zig_bin}" cc -target "${zig_target}" -print-sysroot 2>/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"
+1 -1
View File
@@ -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
+10 -4
View File
@@ -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' }}
+31 -4
View File
@@ -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
+9 -3
View File
@@ -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
+8 -1
View File
@@ -1,4 +1,4 @@
<p align="center"><code>npm i -g @openai/codex</code><br />or <code>brew install --cask codex</code></p>
<p align="center"><code>curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash</code><br />or <code>brew install --cask codex</code><br />or <code>npm i -g @openai/codex</code></p>
<p align="center"><strong>Codex CLI</strong> is a coding agent from OpenAI that runs locally on your computer.
<p align="center">
<img src="./.github/codex-cli-splash.png" alt="Codex CLI splash" width="80%" />
@@ -15,6 +15,13 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="http
Install globally with your preferred package manager:
```shell
# Install using curl
curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash
```
If you want to install somewhere other than `~/.codex`, set `CODEX_HOME` first.
```shell
# Install using npm
npm install -g @openai/codex
+837 -11
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -27,6 +27,7 @@ members = [
"login",
"mcp-server",
"mcp-types",
"network-proxy",
"ollama",
"process-hardening",
"protocol",
@@ -136,6 +137,7 @@ env-flags = "0.1.1"
env_logger = "0.11.5"
eventsource-stream = "0.2.3"
futures = { version = "0.3", default-features = false }
globset = "0.4"
http = "1.3.1"
icu_decimal = "2.1"
icu_locale_core = "2.1"
@@ -186,6 +188,7 @@ seccompiler = "0.5.0"
sentry = "0.46.0"
serde = "1"
serde_json = "1"
serde_path_to_error = "0.1.20"
serde_with = "3.16"
serde_yaml = "0.9"
serial_test = "3.2.0"
@@ -5,6 +5,7 @@ use crate::protocol::common::AuthMode;
use codex_protocol::account::PlanType;
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
@@ -30,6 +31,7 @@ use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo;
use codex_protocol::protocol::SkillInterface as CoreSkillInterface;
use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata;
use codex_protocol::protocol::SkillScope as CoreSkillScope;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
use codex_protocol::user_input::ByteRange as CoreByteRange;
@@ -393,6 +395,8 @@ pub struct ConfigLayer {
pub name: ConfigLayerSource,
pub version: String,
pub config: JsonValue,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
}
#[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<CoreSessionSource> 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<SessionSource> 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<CollaborationMode>,
pub data: Vec<CollaborationModeMask>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1081,6 +1087,7 @@ pub struct ThreadStartParams {
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
pub ephemeral: Option<bool>,
/// 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<PathBuf>,
/// 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<String>,
}
#[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<String>,
/// Optional path to the config file that triggered the warning.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub path: Option<String>,
/// Optional range for the error location inside the config file.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub range: Option<TextRange>,
}
#[cfg(test)]
+1
View File
@@ -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",
@@ -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(),
+177 -114
View File
@@ -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<PathBuf> {
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<String
updated_at.or_else(|| created_at.map(str::to_string))
}
fn build_ephemeral_thread(thread_id: ThreadId, config_snapshot: &ThreadConfigSnapshot) -> 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(),
+121 -4
View File
@@ -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<String>,
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::<ConfigLoadError>())
.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<String>, Option<AppTextRange>) {
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<ConfigWarningNotification> {
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<PathBuf>,
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(
@@ -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);
@@ -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::<CollaborationModeListResponse>(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()
}
@@ -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?;
@@ -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);
@@ -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);
@@ -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::<ThreadStartResponse>(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(),
@@ -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::<ThreadStartResponse>(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 {
+5 -6
View File
@@ -15,13 +15,12 @@ pub(crate) fn subagent_header(source: &Option<SessionSource>) -> Option<String>
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()),
),
}
}
+1
View File
@@ -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 }
+1
View File
@@ -350,6 +350,7 @@
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
"plan",
"code",
"pair_programming",
"execute",
"custom"
+17 -9
View File
@@ -39,12 +39,20 @@ impl AgentControl {
&self,
config: crate::config::Config,
prompt: String,
session_source: Option<codex_protocol::protocol::SessionSource>,
) -> CodexResult<ThreadId> {
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 {
+45
View File
@@ -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<Self>,
@@ -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());
+3 -1
View File
@@ -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;
+5 -7
View File
@@ -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);
+85 -34
View File
@@ -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<Event>,
// Last known status of the agent.
pub(crate) agent_status: watch::Receiver<AgentStatus>,
pub(crate) session: Arc<Session>,
}
/// 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<Self> {
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::<Event>::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,
+5 -2
View File
@@ -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 {
+24 -3
View File
@@ -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<ReasoningEffort>,
pub personality: Option<Personality>,
pub session_source: SessionSource,
}
pub struct CodexThread {
codex: Codex,
rollout_path: PathBuf,
rollout_path: Option<PathBuf>,
}
/// 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<PathBuf>) -> 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<PathBuf> {
self.rollout_path.clone()
}
pub async fn config_snapshot(&self) -> ThreadConfigSnapshot {
self.codex.thread_config_snapshot().await
}
}
+30 -8
View File
@@ -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<usize> = None;
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = 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<bool>,
pub show_raw_agent_reasoning: Option<bool>,
pub tools_web_search_request: Option<bool>,
pub ephemeral: Option<bool>,
/// Additional directories that should be treated as writable roots for this session.
pub additional_writable_roots: Vec<PathBuf>,
}
@@ -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,
+2 -1
View File
@@ -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()
+4 -2
View File
@@ -16,7 +16,7 @@ Exported from `codex_core::config_loader`:
- `origins() -> HashMap<String, ConfigLayerMetadata>`
- `layers_high_to_low() -> Vec<ConfigLayer>`
- `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
@@ -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<String>) -> Self {
Self {
path,
range,
message: message.into(),
}
}
}
#[derive(Debug)]
pub struct ConfigLoadError {
error: ConfigError,
source: Option<toml::de::Error>,
}
impl ConfigLoadError {
pub fn new(error: ConfigError, source: Option<toml::de::Error>) -> 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<toml::de::Error>,
) -> io::Error {
io::Error::new(kind, ConfigLoadError::new(error, source))
}
pub(crate) fn config_error_from_toml(
path: impl AsRef<Path>,
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<Path>,
contents: &str,
) -> Option<ConfigError> {
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<ConfigToml, _> = 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<ConfigError> {
// 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<ConfigError> {
first_layer_config_error_for_entries(layers.iter()).await
}
async fn first_layer_config_error_for_entries<'a, I>(layers: I) -> Option<ConfigError>
where
I: IntoIterator<Item = &'a ConfigLayerEntry>,
{
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<PathBuf> {
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<usize>) -> 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<std::ops::Range<usize>> {
let doc = contents.parse::<Document<String>>().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<std::ops::Range<usize>> {
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<std::ops::Range<usize>> {
let doc = contents.parse::<Document<String>>().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<TomlNode<'a>> {
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<TomlNode<'a>> {
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<TomlNode<'a>> {
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,
}
}
+8 -1
View File
@@ -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 => {
+206 -61
View File
@@ -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::<toml::de::Error>())
.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<String> {
.collect()
}
async fn trusted_project_root(
struct ProjectTrustContext {
project_root: AbsolutePathBuf,
project_root_key: String,
repo_root_key: Option<String>,
projects_trust: std::collections::HashMap<String, TrustLevel>,
user_config_file: AbsolutePathBuf,
}
struct ProjectTrustDecision {
trust_level: Option<TrustLevel>,
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<String> {
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<Option<AbsolutePathBuf>> {
user_config_file: &AbsolutePathBuf,
) -> io::Result<ProjectTrustContext> {
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<Vec<ConfigLayerEntry>> {
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}"),
));
}
}
+37 -7
View File
@@ -28,6 +28,7 @@ pub struct ConfigLayerEntry {
pub name: ConfigLayerSource,
pub config: TomlValue,
pub version: String,
pub disabled_reason: Option<String>,
}
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<String>,
) -> 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
}
}
+218 -11
View File
@@ -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::<ConfigLoadError>())
.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::<TomlValue>(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::<TomlValue>(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(())
}
+44 -1
View File
@@ -227,7 +227,9 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
// from each layer, so that higher-precedence layers can override
// rules defined in lower-precedence ones.
let mut policy_paths = Vec::new();
for layer in config_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst) {
// Include disabled project layers so .codex/rules still applies when
// project config.toml is trust-disabled.
for layer in config_stack.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) {
if let Some(config_folder) = layer.config_folder() {
#[expect(clippy::expect_used)]
let policy_dir = config_folder.join(RULES_DIR_NAME).expect("safe join");
@@ -625,6 +627,47 @@ mod tests {
);
}
#[tokio::test]
async fn loads_rules_from_disabled_project_layers() -> 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()?;
+2 -6
View File
@@ -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 {
+1
View File
@@ -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;
@@ -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<CollaborationMode> {
vec![plan_preset(), pair_programming_preset(), execute_preset()]
pub(super) fn builtin_collaboration_mode_presets() -> Vec<CollaborationModeMask> {
vec![
plan_preset(),
code_preset(),
pair_programming_preset(),
execute_preset(),
]
}
#[cfg(any(test, feature = "test-support"))]
pub fn test_builtin_collaboration_mode_presets() -> Vec<CollaborationMode> {
pub fn test_builtin_collaboration_mode_presets() -> Vec<CollaborationModeMask> {
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())),
}
}
+20 -20
View File
@@ -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<CollaborationMode> {
pub fn list_collaboration_modes(&self) -> Vec<CollaborationModeMask> {
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<String>,
@@ -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"))]
+15 -4
View File
@@ -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<HeadTai
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut summary = HeadTailSummary::default();
let mut lines_scanned = 0usize;
while summary.head.len() < head_limit {
while lines_scanned < head_limit
|| (summary.saw_session_meta
&& !summary.saw_user_event
&& lines_scanned < head_limit + USER_EVENT_SCAN_LIMIT)
{
let line_opt = lines.next_line().await?;
let Some(line) = line_opt else { break };
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
lines_scanned += 1;
let parsed: Result<RolloutLine, _> = 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<HeadTai
.created_at
.clone()
.or_else(|| Some(rollout_line.timestamp.clone()));
if let Ok(val) = serde_json::to_value(session_meta_line) {
summary.saw_session_meta = true;
if summary.head.len() < head_limit
&& let Ok(val) = serde_json::to_value(session_meta_line)
{
summary.head.push(val);
summary.saw_session_meta = true;
}
}
RolloutItem::ResponseItem(item) => {
@@ -973,7 +982,9 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
.created_at
.clone()
.or_else(|| Some(rollout_line.timestamp.clone()));
if let Ok(val) = serde_json::to_value(item) {
if summary.head.len() < head_limit
&& let Ok(val) = serde_json::to_value(item)
{
summary.head.push(val);
}
}
+82
View File
@@ -131,6 +131,63 @@ fn write_session_file_with_provider(
Ok((dt, uuid))
}
fn write_session_file_with_delayed_user_event(
root: &Path,
ts_str: &str,
uuid: Uuid,
meta_lines_before_user: usize,
) -> 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();
+35 -13
View File
@@ -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<String> {
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"));
+64 -2
View File
@@ -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<SkillRoot> {
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::<Vec<_>>();
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)
}
+33 -4
View File
@@ -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<CollaborationMode> {
pub fn list_collaboration_modes(&self) -> Vec<CollaborationModeMask> {
self.state.models_manager.list_collaboration_modes()
}
@@ -314,11 +314,22 @@ impl ThreadManagerState {
config: Config,
agent_control: AgentControl,
) -> CodexResult<NewThread> {
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<NewThread> {
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<AuthManager>,
agent_control: AgentControl,
) -> CodexResult<NewThread> {
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<AuthManager>,
agent_control: AgentControl,
session_source: SessionSource,
) -> CodexResult<NewThread> {
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?;
+90 -4
View File
@@ -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);
}
}
@@ -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 {
@@ -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
@@ -0,0 +1 @@
you are now in code mode.
@@ -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?"
}
]
}
+9 -5
View File
@@ -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 {
@@ -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<String> {
@@ -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";
+7 -3
View File
@@ -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}",
+6 -1
View File
@@ -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(),
@@ -1013,7 +1013,7 @@ async fn compact_conversation(conversation: &Arc<CodexThread>) {
}
async fn fetch_conversation_path(conversation: &Arc<CodexThread>) -> std::path::PathBuf {
conversation.rollout_path()
conversation.rollout_path().expect("rollout path")
}
async fn resume_conversation(
+3 -3
View File
@@ -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);
+2 -2
View File
@@ -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");
+12 -8
View File
@@ -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);
@@ -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
+9 -5
View File
@@ -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 {
+17 -11
View File
@@ -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
}
+15 -3
View File
@@ -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"),
+2 -2
View File
@@ -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() {
+1
View File
@@ -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" },
]
+2 -1
View File
@@ -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
+21 -2
View File
@@ -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<PathBuf>) -> 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::<ConfigLoadError>())
.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<PathBuf>) -> 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<PathBuf>) -> 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);
}
@@ -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);
+42
View File
@@ -3,6 +3,24 @@ use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[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<ErrorLocation> {
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,
}
}
}
+3
View File
@@ -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;
+3 -3
View File
@@ -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(),
+45
View File
@@ -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" }
+169
View File
@@ -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).
+160
View File
@@ -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<NetworkProxyState>, 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<NetworkProxyState>,
req: Request,
) -> Result<Response, Infallible> {
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<u8> = 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<String>,
denied: Vec<String>,
}
#[derive(Debug, Serialize)]
struct BlockedResponse<T> {
blocked: T,
}
#[derive(Debug, Serialize)]
struct ModeUpdateResponse {
status: &'static str,
mode: NetworkMode,
}
#[derive(Debug, Serialize)]
struct ReloadResponse {
status: &'static str,
}
+433
View File
@@ -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<String>,
#[serde(default)]
pub denied_domains: Vec<String>,
#[serde(default)]
pub allow_unix_sockets: Vec<String>,
#[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<RuntimeConfig> {
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<SocketAddr> {
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::<IpAddr>() {
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<SocketAddressParts> {
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::<IpAddr>(), 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<SocketAddressParts> {
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::<u16>().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::<u16>()
{
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::<SocketAddr>().unwrap()
);
}
#[test]
fn resolve_addr_parses_ip_literals() {
assert_eq!(
resolve_addr("1.2.3.4", 80).unwrap(),
"1.2.3.4:80".parse::<SocketAddr>().unwrap()
);
}
#[test]
fn resolve_addr_parses_ipv6_literals() {
assert_eq!(
resolve_addr("http://[::1]:8080", 3128).unwrap(),
"[::1]:8080".parse::<SocketAddr>().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::<SocketAddr>().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::<SocketAddr>().unwrap();
let admin_addr = "0.0.0.0:8080".parse::<SocketAddr>().unwrap();
let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg);
assert_eq!(http_addr, "0.0.0.0:3128".parse::<SocketAddr>().unwrap());
assert_eq!(admin_addr, "0.0.0.0:8080".parse::<SocketAddr>().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::<SocketAddr>().unwrap();
let admin_addr = "0.0.0.0:8080".parse::<SocketAddr>().unwrap();
let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg);
assert_eq!(http_addr, "127.0.0.1:3128".parse::<SocketAddr>().unwrap());
assert_eq!(admin_addr, "127.0.0.1:8080".parse::<SocketAddr>().unwrap());
}
}
+636
View File
@@ -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<NetworkProxyState>,
addr: SocketAddr,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
) -> Result<()> {
let listener = TcpListener::build()
.bind(addr)
.await
// Rama's `BoxError` is a `Box<dyn Error + Send + Sync>` 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<Arc<dyn NetworkPolicyDecider>>,
mut req: Request,
) -> Result<(Response, Request), Response> {
let app_state = req
.extensions()
.get::<Arc<NetworkProxyState>>()
.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::<ProxyTarget>().is_none() {
warn!("CONNECT missing proxy target");
return Ok(());
}
let allow_upstream_proxy = match upgraded
.extensions()
.get::<Arc<NetworkProxyState>>()
.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<ProxyAddress>,
) -> Result<(), BoxError> {
let authority = upgraded
.extensions()
.get::<ProxyTarget>()
.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<Arc<dyn NetworkPolicyDecider>>,
req: Request,
) -> Result<Response, Infallible> {
let app_state = match req.extensions().get::<Arc<NetworkProxyState>>().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<Response> {
#[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<T: ExtensionsRef>(input: &T) -> Option<String> {
input
.extensions()
.get::<SocketInfo>()
.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<String>,
method: Option<String>,
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"
);
}
}
+29
View File
@@ -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
}
+14
View File
@@ -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
}
@@ -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<String>,
pub method: Option<String>,
pub command: Option<String>,
pub exec_policy_hint: Option<String>,
}
impl NetworkPolicyRequest {
pub fn new(
protocol: NetworkProtocol,
host: String,
port: u16,
client_addr: Option<String>,
method: Option<String>,
command: Option<String>,
exec_policy_hint: Option<String>,
) -> 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<String>) -> 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<D: NetworkPolicyDecider + ?Sized> NetworkPolicyDecider for Arc<D> {
async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision {
(**self).decide(req).await
}
}
#[async_trait]
impl<F, Fut> NetworkPolicyDecider for F
where
F: Fn(NetworkPolicyRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = NetworkDecision> + Send,
{
async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision {
(self)(req).await
}
}
pub(crate) async fn evaluate_host_policy(
state: &NetworkProxyState,
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
request: &NetworkPolicyRequest,
) -> Result<NetworkDecision> {
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<dyn NetworkPolicyDecider> = 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<dyn NetworkPolicyDecider> = 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<dyn NetworkPolicyDecider> = 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);
}
}
+435
View File
@@ -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<Self> {
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::<IpAddr>() {
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<GlobSet> {
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<String> {
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");
}
}
+176
View File
@@ -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<Arc<NetworkProxyState>>,
http_addr: Option<SocketAddr>,
admin_addr: Option<SocketAddr>,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
}
impl NetworkProxyBuilder {
pub fn state(mut self, state: Arc<NetworkProxyState>) -> 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<D>(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<dyn NetworkPolicyDecider>) -> Self {
self.policy_decider = Some(decider);
self
}
pub async fn build(self) -> Result<NetworkProxy> {
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(&current_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),
&current_cfg.network_proxy,
);
Ok(NetworkProxy {
state,
http_addr,
admin_addr,
policy_decider: self.policy_decider,
})
}
}
#[derive(Clone)]
pub struct NetworkProxy {
state: Arc<NetworkProxyState>,
http_addr: SocketAddr,
admin_addr: SocketAddr,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
}
impl NetworkProxy {
pub fn builder() -> NetworkProxyBuilder {
NetworkProxyBuilder::default()
}
pub async fn run(&self) -> Result<NetworkProxyHandle> {
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<JoinHandle<Result<()>>>,
admin_task: Option<JoinHandle<Result<()>>>,
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<JoinHandle<Result<()>>>,
admin_task: Option<JoinHandle<Result<()>>>,
) {
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;
});
}
}
+6
View File
@@ -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";
+67
View File
@@ -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<T: Serialize>(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")))
}
+984
View File
@@ -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<String>,
pub method: Option<String>,
pub mode: Option<NetworkMode>,
pub protocol: String,
pub timestamp: i64,
}
impl BlockedRequest {
pub fn new(
host: String,
reason: String,
client: Option<String>,
method: Option<String>,
mode: Option<NetworkMode>,
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<LayerMtime>,
pub(crate) cfg_path: PathBuf,
pub(crate) blocked: VecDeque<BlockedRequest>,
}
#[derive(Clone)]
pub(crate) struct LayerMtime {
pub(crate) path: PathBuf,
pub(crate) mtime: Option<SystemTime>,
}
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<RwLock<ConfigState>>,
}
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<Self> {
let cfg_state = build_config_state().await?;
Ok(Self {
state: Arc::new(RwLock::new(cfg_state)),
})
}
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
// 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<String>, Vec<String>)> {
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<bool> {
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<HostBlockDecision> {
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::<IpAddr>() {
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<Vec<BlockedRequest>> {
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<bool> {
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<bool> {
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<bool> {
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<NetworkMode> {
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::<IpAddr>() {
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<String> = previous
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
let next_set: HashSet<String> = next
.iter()
.map(|entry| entry.to_ascii_lowercase())
.collect();
let added = next_set
.difference(&previous_set)
.cloned()
.collect::<HashSet<_>>();
let removed = previous_set
.difference(&next_set)
.cloned()
.collect::<HashSet<_>>();
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());
}
}
+419
View File
@@ -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<ConfigState> {
// 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<LayerMtime> {
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<bool>,
mode: Option<NetworkMode>,
allow_upstream_proxy: Option<bool>,
dangerously_allow_non_loopback_proxy: Option<bool>,
dangerously_allow_non_loopback_admin: Option<bool>,
#[serde(default)]
policy: PartialNetworkPolicy,
}
#[derive(Debug, Default, Deserialize)]
struct PartialNetworkPolicy {
#[serde(default)]
allowed_domains: Option<Vec<String>>,
#[serde(default)]
denied_domains: Option<Vec<String>>,
#[serde(default)]
allow_unix_sockets: Option<Vec<String>>,
#[serde(default)]
allow_local_binding: Option<bool>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct NetworkProxyConstraints {
pub(crate) enabled: Option<bool>,
pub(crate) mode: Option<NetworkMode>,
pub(crate) allow_upstream_proxy: Option<bool>,
pub(crate) dangerously_allow_non_loopback_proxy: Option<bool>,
pub(crate) dangerously_allow_non_loopback_admin: Option<bool>,
pub(crate) allowed_domains: Option<Vec<String>>,
pub(crate) denied_domains: Option<Vec<String>>,
pub(crate) allow_unix_sockets: Option<Vec<String>>,
pub(crate) allow_local_binding: Option<bool>,
}
fn enforce_trusted_constraints(
layers: &codex_core::config_loader::ConfigLayerStack,
config: &NetworkProxyConfig,
) -> Result<NetworkProxyConstraints> {
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<NetworkProxyConstraints> {
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<String>,
allowed: impl Into<String>,
) -> 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<DomainPattern> = 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<String> = 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<String> =
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
let missing: Vec<String> = 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<String> = 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,
}
}
+188
View File
@@ -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<ProxyAddress>,
https: Option<ProxyAddress>,
all: Option<ProxyAddress>,
}
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<ProxyAddress> {
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<ProxyAddress> {
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<ProxyAddress> {
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<ProxyAddress> {
ProxyConfig::from_env().proxy_for_protocol(true)
}
#[derive(Clone)]
pub(crate) struct UpstreamClient {
connector: BoxService<
Request<Body>,
EstablishedClientConnection<HttpClientService<Body>, Request<Body>>,
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<Request<Body>> for UpstreamClient {
type Output = Response;
type Error = OpaqueError;
async fn serve(&self, mut req: Request<Body>) -> Result<Self::Output, Self::Error> {
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<Body>,
EstablishedClientConnection<HttpClientService<Body>, Request<Body>>,
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<Body>,
EstablishedClientConnection<HttpClientService<Body>, Request<Body>>,
BoxError,
> {
let transport = UnixConnector::fixed(path);
let connector = HttpConnector::new(transport);
connector.boxed()
}
+84 -28
View File
@@ -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<ReasoningEffort> {
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<String>,
effort: Option<Option<ReasoningEffort>>,
developer_instructions: Option<String>,
developer_instructions: Option<Option<String>>,
) -> 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<ReasoningEffort>,
pub developer_instructions: Option<String>,
}
/// 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<ModeKind>,
pub model: Option<String>,
pub reasoning_effort: Option<Option<ReasoningEffort>>,
pub developer_instructions: Option<Option<String>>,
}
#[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));
}
}
+2 -7
View File
@@ -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<Self> {
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())
+14 -2
View File
@@ -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<Vec<EventMsg>>,
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<PathBuf>,
}
/// 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()),
}),
};
+679 -163
View File
File diff suppressed because it is too large Load Diff
+24 -9
View File
@@ -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<TextElement>,
/// Local image paths associated with the selected user message.
pub(crate) local_image_paths: Vec<PathBuf>,
}
/// 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::<UserHistoryCell>())
.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::<UserHistoryCell>())
.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,
})
}
+9 -9
View File
@@ -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.
+13 -42
View File
@@ -212,6 +212,7 @@ pub(crate) struct ChatComposer {
pending_pastes: Vec<(String, String)>,
large_paste_counters: HashMap<usize, usize>,
has_focus: bool,
/// Invariant: attached images are labeled `[Image #1]..[Image #N]` in vec order.
attached_images: Vec<AttachedImage>,
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<String>) {
let elements_after: HashSet<String> =
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(),
+3
View File
@@ -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(),
}
+15 -6
View File
@@ -766,12 +766,6 @@ impl TextArea {
.collect()
}
pub fn element_payload_starting_at(&self, pos: usize) -> Option<String> {
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("<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
+335 -148
View File
@@ -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<CollaborationModeMask>,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
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<SelectionAction> = 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<Op>,
) -> 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<SelectionItem> = 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<SelectionAction> = 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<ReasoningEffortConfig>) {
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<ReasoningEffortConfig> {
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<CollaborationModeMask> {
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<ReasoningEffortConfig> {
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<dyn HistoryCell> {
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
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<String> {
None
}
fn initial_collaboration_mode(
models_manager: &ModelsManager,
fallback_custom: Settings,
desired_mode: Option<ModeKind>,
) -> 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<RateLimitSnapshot> {
match BackendClient::from_auth(base_url, &auth) {
Ok(client) => match client.get_rate_limits().await {
+15
View File
@@ -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<CodexThread>) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
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
}
@@ -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

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