From e9e7ef3d366fdb95c24d24fea734b0fa47d79fc4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 10 Apr 2026 23:08:02 -0700 Subject: [PATCH 1/9] Fix thread/list cwd filtering for Windows verbatim paths (#17414) Addresses #17302 Problem: `thread/list` compared cwd filters with raw path equality, so `resume --last` could miss Windows sessions when the saved cwd used a verbatim path form and the current cwd did not. Solution: Normalize cwd comparisons through the existing path comparison utilities before falling back to direct equality, and add Windows regression coverage for verbatim paths. I made this a general utility function and replaced all of the duplicated instance of it across the code base. --- .../app-server/src/codex_message_processor.rs | 7 ++-- codex-rs/core/src/config/service.rs | 9 +---- codex-rs/core/src/tools/runtimes/mod.rs | 9 +---- codex-rs/exec/src/lib.rs | 8 +---- codex-rs/rollout/src/recorder.rs | 8 +---- codex-rs/tui/src/lib.rs | 8 +---- codex-rs/tui/src/resume_picker.rs | 8 +---- codex-rs/utils/path-utils/src/lib.rs | 13 +++++++ .../utils/path-utils/src/path_utils_tests.rs | 35 +++++++++++++++++++ 9 files changed, 58 insertions(+), 47 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index a71abc4003..530bc7093d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -222,6 +222,7 @@ use codex_core::find_thread_name_by_id; use codex_core::find_thread_names_by_ids; use codex_core::find_thread_path_by_id_str; use codex_core::parse_cursor; +use codex_core::path_utils; use codex_core::plugins::MarketplaceError; use codex_core::plugins::MarketplacePluginSource; use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; @@ -4768,9 +4769,9 @@ impl CodexMessageProcessor { if source_kind_filter .as_ref() .is_none_or(|filter| source_kind_matches(&summary.source, filter)) - && cwd - .as_ref() - .is_none_or(|expected_cwd| &summary.cwd == expected_cwd) + && cwd.as_ref().is_none_or(|expected_cwd| { + path_utils::paths_match_after_normalization(&summary.cwd, expected_cwd) + }) { filtered.push(summary); if filtered.len() >= remaining { diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 80d4b6bdd9..6c7f071f2b 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -629,14 +629,7 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { } fn paths_match(expected: impl AsRef, provided: impl AsRef) -> bool { - if let (Ok(expanded_expected), Ok(expanded_provided)) = ( - path_utils::normalize_for_path_comparison(&expected), - path_utils::normalize_for_path_comparison(&provided), - ) { - expanded_expected == expanded_provided - } else { - expected.as_ref() == provided.as_ref() - } + path_utils::paths_match_after_normalization(expected, provided) } fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> { diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 5ea14b8a8f..795f0112d9 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -76,14 +76,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( return command.to_vec(); } - if if let (Ok(snapshot_cwd), Ok(command_cwd)) = ( - path_utils::normalize_for_path_comparison(snapshot.cwd.as_path()), - path_utils::normalize_for_path_comparison(cwd), - ) { - snapshot_cwd != command_cwd - } else { - snapshot.cwd != cwd - } { + if !path_utils::paths_match_after_normalization(snapshot.cwd.as_path(), cwd) { return command.to_vec(); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 81a0961fa4..31099e8d65 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1205,13 +1205,7 @@ async fn parse_latest_turn_context_cwd(path: &Path) -> Option { } fn cwds_match(current_cwd: &Path, session_cwd: &Path) -> bool { - match ( - path_utils::normalize_for_path_comparison(current_cwd), - path_utils::normalize_for_path_comparison(session_cwd), - ) { - (Ok(current), Ok(session)) => current == session, - _ => current_cwd == session_cwd, - } + path_utils::paths_match_after_normalization(current_cwd, session_cwd) } async fn resolve_resume_thread_id( diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index a142144089..01ea10cb95 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -1342,13 +1342,7 @@ async fn select_resume_path_from_db_page( } fn cwd_matches(session_cwd: &Path, cwd: &Path) -> bool { - if let (Ok(ca), Ok(cb)) = ( - path_utils::normalize_for_path_comparison(session_cwd), - path_utils::normalize_for_path_comparison(cwd), - ) { - return ca == cb; - } - session_cwd == cwd + path_utils::paths_match_after_normalization(session_cwd, cwd) } #[cfg(test)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cd5049499d..1e3b58452b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1524,13 +1524,7 @@ async fn read_latest_turn_context(path: &Path) -> Option { } pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool { - match ( - path_utils::normalize_for_path_comparison(current_cwd), - path_utils::normalize_for_path_comparison(session_cwd), - ) { - (Ok(current), Ok(session)) => current != session, - _ => current_cwd != session_cwd, - } + !path_utils::paths_match_after_normalization(current_cwd, session_cwd) } pub(crate) enum ResolveCwdOutcome { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index a3c076a5fb..d50d00bdda 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1148,13 +1148,7 @@ fn thread_list_params( } fn paths_match(a: &Path, b: &Path) -> bool { - if let (Ok(ca), Ok(cb)) = ( - path_utils::normalize_for_path_comparison(a), - path_utils::normalize_for_path_comparison(b), - ) { - return ca == cb; - } - a == b + path_utils::paths_match_after_normalization(a, b) } #[cfg_attr(not(test), allow(dead_code))] diff --git a/codex-rs/utils/path-utils/src/lib.rs b/codex-rs/utils/path-utils/src/lib.rs index 7e44e06d13..5390250a1b 100644 --- a/codex-rs/utils/path-utils/src/lib.rs +++ b/codex-rs/utils/path-utils/src/lib.rs @@ -16,6 +16,19 @@ pub fn normalize_for_path_comparison(path: impl AsRef) -> std::io::Result< Ok(normalize_for_wsl(canonical)) } +/// Compare paths after applying Codex's filesystem normalization. +/// +/// If either path cannot be normalized, this falls back to direct path equality. +pub fn paths_match_after_normalization(left: impl AsRef, right: impl AsRef) -> bool { + if let (Ok(left), Ok(right)) = ( + normalize_for_path_comparison(left.as_ref()), + normalize_for_path_comparison(right.as_ref()), + ) { + return left == right; + } + left.as_ref() == right.as_ref() +} + pub fn normalize_for_native_workdir(path: impl AsRef) -> PathBuf { normalize_for_native_workdir_with_flag(path.as_ref().to_path_buf(), cfg!(windows)) } diff --git a/codex-rs/utils/path-utils/src/path_utils_tests.rs b/codex-rs/utils/path-utils/src/path_utils_tests.rs index bda4b77ab3..94c914fec2 100644 --- a/codex-rs/utils/path-utils/src/path_utils_tests.rs +++ b/codex-rs/utils/path-utils/src/path_utils_tests.rs @@ -78,3 +78,38 @@ mod native_workdir { assert_eq!(normalized, path); } } + +mod path_comparison { + use super::super::paths_match_after_normalization; + use std::path::PathBuf; + + #[test] + fn matches_identical_existing_paths() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + + assert!(paths_match_after_normalization(dir.path(), dir.path())); + Ok(()) + } + + #[test] + fn falls_back_to_raw_equality_when_paths_cannot_be_normalized() { + assert!(paths_match_after_normalization( + PathBuf::from("missing"), + PathBuf::from("missing"), + )); + assert!(!paths_match_after_normalization( + PathBuf::from("missing-a"), + PathBuf::from("missing-b"), + )); + } + + #[cfg(windows)] + #[test] + fn matches_windows_verbatim_paths() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + let verbatim_dir = PathBuf::from(format!(r"\\?\{}", dir.path().display())); + + assert!(paths_match_after_normalization(verbatim_dir, dir.path())); + Ok(()) + } +} From dbfe855f4fd0f5dcdf079882652a8efe622b0595 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 10 Apr 2026 23:32:06 -0700 Subject: [PATCH 2/9] feat(devcontainer): add separate secure customer profile (#10431) ## Description Keeps the existing Codex contributor devcontainer in place and adds a separate secure profile for customer use. ## What changed - leaves `.devcontainer/devcontainer.json` and the contributor `Dockerfile` aligned with `main` - adds `.devcontainer/devcontainer.secure.json` and `.devcontainer/Dockerfile.secure` - adds secure-profile bootstrap scripts: - `post_install.py` - `post-start.sh` - `init-firewall.sh` - updates `.devcontainer/README.md` to explain when to use each path ## Secure profile behavior The new secure profile is opt-in and is meant for running Codex in a stricter project container: - preinstalls the Codex CLI plus common build tools - uses persistent volumes for Codex state, Cargo, Rustup, and GitHub auth - applies an allowlist-driven outbound firewall at startup - blocks IPv6 by default so the allowlist cannot be bypassed via AAAA routes - keeps the stricter networking isolated from the default contributor workflow ## Resulting behavior - `devcontainer.json` remains the low-friction Codex contributor setup - `devcontainer.secure.json` is the customer-facing secure option - the repo supports both workflows without forcing the secure profile on Codex contributors --- .devcontainer/Dockerfile.secure | 71 +++++++++++ .devcontainer/README.md | 45 ++++--- .devcontainer/devcontainer.secure.json | 76 +++++++++++ .devcontainer/init-firewall.sh | 170 +++++++++++++++++++++++++ .devcontainer/post-start.sh | 36 ++++++ .devcontainer/post_install.py | 113 ++++++++++++++++ 6 files changed, 496 insertions(+), 15 deletions(-) create mode 100644 .devcontainer/Dockerfile.secure create mode 100644 .devcontainer/devcontainer.secure.json create mode 100644 .devcontainer/init-firewall.sh create mode 100644 .devcontainer/post-start.sh create mode 100644 .devcontainer/post_install.py diff --git a/.devcontainer/Dockerfile.secure b/.devcontainer/Dockerfile.secure new file mode 100644 index 0000000000..aaff9eb2d3 --- /dev/null +++ b/.devcontainer/Dockerfile.secure @@ -0,0 +1,71 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 + +ARG TZ +ARG DEBIAN_FRONTEND=noninteractive +ARG NODE_MAJOR=22 +ARG RUST_TOOLCHAIN=1.92.0 +ARG CODEX_NPM_VERSION=latest + +ENV TZ="$TZ" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + git \ + ca-certificates \ + pkg-config \ + clang \ + musl-tools \ + libssl-dev \ + libsqlite3-dev \ + just \ + python3 \ + python3-pip \ + jq \ + less \ + man-db \ + unzip \ + ripgrep \ + fzf \ + fd-find \ + zsh \ + dnsutils \ + iproute2 \ + ipset \ + iptables \ + aggregate \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g corepack@latest "@openai/codex@${CODEX_NPM_VERSION}" \ + && corepack enable \ + && corepack prepare pnpm@10.28.2 --activate \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY .devcontainer/init-firewall.sh /usr/local/bin/init-firewall.sh +COPY .devcontainer/post_install.py /opt/post_install.py +COPY .devcontainer/post-start.sh /opt/post_start.sh + +RUN chmod 500 /usr/local/bin/init-firewall.sh \ + && chmod 755 /opt/post_start.sh \ + && chmod 644 /opt/post_install.py \ + && chown vscode:vscode /opt/post_install.py + +RUN install -d -m 0775 -o vscode -g vscode /commandhistory /workspace \ + && touch /commandhistory/.bash_history /commandhistory/.zsh_history \ + && chown vscode:vscode /commandhistory/.bash_history /commandhistory/.zsh_history + +USER vscode +ENV PATH="/home/vscode/.cargo/bin:${PATH}" +WORKDIR /workspace + +RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}" \ + && rustup component add clippy rustfmt rust-src \ + && rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 58e4458a06..a5e114d37a 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -1,10 +1,36 @@ # Containerized Development -We provide the following options to facilitate Codex development in a container. This is particularly useful for verifying the Linux build when working on a macOS host. +We provide two container paths: + +- `devcontainer.json` keeps the existing Codex contributor setup for working on this repository. +- `devcontainer.secure.json` adds a customer-oriented profile with stricter outbound network controls. + +## Codex contributor profile + +Use `devcontainer.json` when you are developing Codex itself. This is the same lightweight arm64 container that already exists in the repo. + +## Secure customer profile + +Use `devcontainer.secure.json` when you want a stricter runtime profile for running Codex inside a project container: + +- installs the Codex CLI plus common build tools +- enables firewall startup with an allowlist-driven outbound policy +- blocks IPv6 by default so the allowlist cannot be bypassed over AAAA routes +- requires `NET_ADMIN` and `NET_RAW` so the firewall can be installed at startup + +This profile keeps the stricter networking isolated to the customer path instead of changing the default Codex contributor container. + +Start it from the CLI with: + +```bash +devcontainer up --workspace-folder . --config .devcontainer/devcontainer.secure.json +``` + +In VS Code, choose **Dev Containers: Open Folder in Container...** and select `.devcontainer/devcontainer.secure.json`. ## Docker -To build the Docker image locally for x64 and then run it with the repo mounted under `/workspace`: +To build the contributor image locally for x64 and then run it with the repo mounted under `/workspace`: ```shell CODEX_DOCKER_IMAGE_NAME=codex-linux-dev @@ -14,17 +40,6 @@ docker run --platform=linux/amd64 --rm -it -e CARGO_TARGET_DIR=/workspace/codex- Note that `/workspace/target` will contain the binaries built for your host platform, so we include `-e CARGO_TARGET_DIR=/workspace/codex-rs/target-amd64` in the `docker run` command so that the binaries built inside your container are written to a separate directory. -For arm64, specify `--platform=linux/amd64` instead for both `docker build` and `docker run`. +For arm64, specify `--platform=linux/arm64` instead for both `docker build` and `docker run`. -Currently, the `Dockerfile` works for both x64 and arm64 Linux, though you need to run `rustup target add x86_64-unknown-linux-musl` yourself to install the musl toolchain for x64. - -## VS Code - -VS Code recognizes the `devcontainer.json` file and gives you the option to develop Codex in a container. Currently, `devcontainer.json` builds and runs the `arm64` flavor of the container. - -From the integrated terminal in VS Code, you can build either flavor of the `arm64` build (GNU or musl): - -```shell -cargo build --target aarch64-unknown-linux-musl -cargo build --target aarch64-unknown-linux-gnu -``` +Currently, the contributor `Dockerfile` works for both x64 and arm64 Linux, though you need to run `rustup target add x86_64-unknown-linux-musl` yourself to install the musl toolchain for x64. diff --git a/.devcontainer/devcontainer.secure.json b/.devcontainer/devcontainer.secure.json new file mode 100644 index 0000000000..a7f1f6c654 --- /dev/null +++ b/.devcontainer/devcontainer.secure.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json", + "name": "Codex (Secure)", + "build": { + "dockerfile": "Dockerfile.secure", + "context": "..", + "args": { + "TZ": "${localEnv:TZ:UTC}", + "NODE_MAJOR": "22", + "RUST_TOOLCHAIN": "1.92.0", + "CODEX_NPM_VERSION": "latest" + } + }, + "runArgs": [ + "--cap-add=NET_ADMIN", + "--cap-add=NET_RAW" + ], + "init": true, + "updateRemoteUserUID": true, + "remoteUser": "vscode", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", + "workspaceFolder": "/workspace", + "mounts": [ + "source=codex-commandhistory-${devcontainerId},target=/commandhistory,type=volume", + "source=codex-home-${devcontainerId},target=/home/vscode/.codex,type=volume", + "source=codex-gh-${devcontainerId},target=/home/vscode/.config/gh,type=volume", + "source=codex-cargo-registry-${devcontainerId},target=/home/vscode/.cargo/registry,type=volume", + "source=codex-cargo-git-${devcontainerId},target=/home/vscode/.cargo/git,type=volume", + "source=codex-rustup-${devcontainerId},target=/home/vscode/.rustup,type=volume", + "source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,readonly" + ], + "containerEnv": { + "RUST_BACKTRACE": "1", + "CODEX_UNSAFE_ALLOW_NO_SANDBOX": "1", + "CODEX_ENABLE_FIREWALL": "1", + "CODEX_INCLUDE_GITHUB_META_RANGES": "1", + "OPENAI_ALLOWED_DOMAINS": "api.openai.com auth.openai.com github.com api.github.com codeload.github.com raw.githubusercontent.com objects.githubusercontent.com crates.io index.crates.io static.crates.io static.rust-lang.org registry.npmjs.org pypi.org files.pythonhosted.org", + "CARGO_TARGET_DIR": "/workspace/.cache/cargo-target", + "GIT_CONFIG_GLOBAL": "/home/vscode/.gitconfig.local", + "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0", + "PYTHONDONTWRITEBYTECODE": "1", + "PIP_DISABLE_PIP_VERSION_CHECK": "1" + }, + "remoteEnv": { + "OPENAI_API_KEY": "${localEnv:OPENAI_API_KEY}" + }, + "postCreateCommand": "python3 /opt/post_install.py", + "postStartCommand": "bash /opt/post_start.sh", + "waitFor": "postStartCommand", + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.profiles.linux": { + "bash": { + "path": "bash", + "icon": "terminal-bash" + }, + "zsh": { + "path": "zsh" + } + }, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true + }, + "extensions": [ + "openai.chatgpt", + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "vadimcn.vscode-lldb", + "ms-azuretools.vscode-docker" + ] + } + } +} diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh new file mode 100644 index 0000000000..9275724473 --- /dev/null +++ b/.devcontainer/init-firewall.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +allowed_domains_file="/etc/codex/allowed_domains.txt" +include_github_meta_ranges="${CODEX_INCLUDE_GITHUB_META_RANGES:-1}" + +if [ -f "$allowed_domains_file" ]; then + mapfile -t allowed_domains < <(sed '/^\s*#/d;/^\s*$/d' "$allowed_domains_file") +else + allowed_domains=("api.openai.com") +fi + +if [ "${#allowed_domains[@]}" -eq 0 ]; then + echo "ERROR: No allowed domains configured" + exit 1 +fi + +add_ipv4_cidr_to_allowlist() { + local source="$1" + local cidr="$2" + + if [[ ! "$cidr" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$ ]]; then + echo "ERROR: Invalid ${source} CIDR range: $cidr" + exit 1 + fi + + ipset add allowed-domains "$cidr" -exist +} + +configure_ipv6_default_deny() { + if ! command -v ip6tables >/dev/null 2>&1; then + echo "ERROR: ip6tables is required to enforce IPv6 default-deny policy" + exit 1 + fi + + ip6tables -F + ip6tables -X + ip6tables -t mangle -F + ip6tables -t mangle -X + ip6tables -t nat -F 2>/dev/null || true + ip6tables -t nat -X 2>/dev/null || true + + ip6tables -A INPUT -i lo -j ACCEPT + ip6tables -A OUTPUT -o lo -j ACCEPT + ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + + ip6tables -P INPUT DROP + ip6tables -P FORWARD DROP + ip6tables -P OUTPUT DROP + + echo "IPv6 firewall policy configured (default-deny)" +} + +# Preserve docker-managed DNS NAT rules before clearing tables. +docker_dns_rules="$(iptables-save -t nat | grep "127\\.0\\.0\\.11" || true)" + +iptables -F +iptables -X +iptables -t nat -F +iptables -t nat -X +iptables -t mangle -F +iptables -t mangle -X +ipset destroy allowed-domains 2>/dev/null || true + +if [ -n "$docker_dns_rules" ]; then + echo "Restoring Docker DNS NAT rules" + iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true + iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true + while IFS= read -r rule; do + [ -z "$rule" ] && continue + iptables -t nat $rule + done <<< "$docker_dns_rules" +fi + +# Allow DNS resolution and localhost communication. +iptables -A OUTPUT -p udp --dport 53 -j ACCEPT +iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT +iptables -A INPUT -p udp --sport 53 -j ACCEPT +iptables -A INPUT -p tcp --sport 53 -j ACCEPT +iptables -A INPUT -i lo -j ACCEPT +iptables -A OUTPUT -o lo -j ACCEPT + +ipset create allowed-domains hash:net + +for domain in "${allowed_domains[@]}"; do + echo "Resolving $domain" + ips="$(dig +short A "$domain" | sed '/^\s*$/d')" + if [ -z "$ips" ]; then + echo "ERROR: Failed to resolve $domain" + exit 1 + fi + + while IFS= read -r ip; do + if [[ ! "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + echo "ERROR: Invalid IPv4 address from DNS for $domain: $ip" + exit 1 + fi + ipset add allowed-domains "$ip" -exist + done <<< "$ips" +done + +if [ "$include_github_meta_ranges" = "1" ]; then + echo "Fetching GitHub meta ranges" + github_meta="$(curl -fsSL --connect-timeout 10 https://api.github.com/meta)" + + if ! echo "$github_meta" | jq -e '.web and .api and .git' >/dev/null; then + echo "ERROR: GitHub meta response missing expected fields" + exit 1 + fi + + while IFS= read -r cidr; do + [ -z "$cidr" ] && continue + if [[ "$cidr" == *:* ]]; then + # Current policy enforces IPv4-only ipset entries. + continue + fi + add_ipv4_cidr_to_allowlist "GitHub" "$cidr" + done < <(echo "$github_meta" | jq -r '((.web // []) + (.api // []) + (.git // []))[]' | sort -u) +fi + +host_ip="$(ip route | awk '/default/ {print $3; exit}')" +if [ -z "$host_ip" ]; then + echo "ERROR: Failed to detect host IP" + exit 1 +fi + +host_network="$(echo "$host_ip" | sed 's/\.[0-9]*$/.0\/24/')" +iptables -A INPUT -s "$host_network" -j ACCEPT +iptables -A OUTPUT -d "$host_network" -j ACCEPT + +iptables -P INPUT DROP +iptables -P FORWARD DROP +iptables -P OUTPUT DROP + +iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT + +# Reject rather than silently drop to make policy failures obvious. +iptables -A INPUT -j REJECT --reject-with icmp-admin-prohibited +iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited +iptables -A FORWARD -j REJECT --reject-with icmp-admin-prohibited + +configure_ipv6_default_deny + +echo "Firewall configuration complete" + +if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then + echo "ERROR: Firewall verification failed - was able to reach https://example.com" + exit 1 +fi + +if ! curl --connect-timeout 5 https://api.openai.com >/dev/null 2>&1; then + echo "ERROR: Firewall verification failed - unable to reach https://api.openai.com" + exit 1 +fi + +if [ "$include_github_meta_ranges" = "1" ] && ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then + echo "ERROR: Firewall verification failed - unable to reach https://api.github.com" + exit 1 +fi + +if curl --connect-timeout 5 -6 https://example.com >/dev/null 2>&1; then + echo "ERROR: Firewall verification failed - was able to reach https://example.com over IPv6" + exit 1 +fi + +echo "Firewall verification passed" diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100644 index 0000000000..fcc42ad145 --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "${CODEX_ENABLE_FIREWALL:-1}" != "1" ]; then + echo "[devcontainer] Firewall mode: permissive (CODEX_ENABLE_FIREWALL=${CODEX_ENABLE_FIREWALL:-unset})." + exit 0 +fi + +echo "[devcontainer] Firewall mode: strict" + +domains_raw="${OPENAI_ALLOWED_DOMAINS:-api.openai.com}" +mapfile -t domains < <(printf '%s\n' "$domains_raw" | tr ', ' '\n\n' | sed '/^$/d' | sort -u) + +if [ "${#domains[@]}" -eq 0 ]; then + echo "[devcontainer] No allowed domains configured." + exit 1 +fi + +tmp_file="$(mktemp)" +for domain in "${domains[@]}"; do + if [[ ! "$domain" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$ ]]; then + echo "[devcontainer] Invalid domain in OPENAI_ALLOWED_DOMAINS: $domain" + rm -f "$tmp_file" + exit 1 + fi + printf '%s\n' "$domain" >> "$tmp_file" +done + +sudo install -d -m 0755 /etc/codex +sudo cp "$tmp_file" /etc/codex/allowed_domains.txt +sudo chown root:root /etc/codex/allowed_domains.txt +sudo chmod 0444 /etc/codex/allowed_domains.txt +rm -f "$tmp_file" + +echo "[devcontainer] Applying firewall policy for domains: ${domains[*]}" +sudo --preserve-env=CODEX_INCLUDE_GITHUB_META_RANGES /usr/local/bin/init-firewall.sh diff --git a/.devcontainer/post_install.py b/.devcontainer/post_install.py new file mode 100644 index 0000000000..205e57ce4c --- /dev/null +++ b/.devcontainer/post_install.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Post-install configuration for the Codex devcontainer.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def ensure_history_files() -> None: + command_history_dir = Path("/commandhistory") + command_history_dir.mkdir(parents=True, exist_ok=True) + + for filename in (".bash_history", ".zsh_history"): + (command_history_dir / filename).touch(exist_ok=True) + + +def fix_directory_ownership() -> None: + uid = os.getuid() + gid = os.getgid() + + paths = [ + Path.home() / ".codex", + Path.home() / ".config" / "gh", + Path.home() / ".cargo", + Path.home() / ".rustup", + Path("/commandhistory"), + ] + + for path in paths: + if not path.exists(): + continue + + stat_info = path.stat() + if stat_info.st_uid == uid and stat_info.st_gid == gid: + continue + + try: + subprocess.run( + ["sudo", "chown", "-R", f"{uid}:{gid}", str(path)], + check=True, + capture_output=True, + text=True, + ) + print(f"[post_install] fixed ownership: {path}", file=sys.stderr) + except subprocess.CalledProcessError as err: + print( + f"[post_install] warning: could not fix ownership of {path}: {err.stderr.strip()}", + file=sys.stderr, + ) + + +def setup_git_config() -> None: + home = Path.home() + host_gitconfig = home / ".gitconfig" + local_gitconfig = home / ".gitconfig.local" + gitignore_global = home / ".gitignore_global" + + gitignore_global.write_text( + """# Codex +.codex/ + +# Rust +/target/ + +# Node +node_modules/ + +# Python +__pycache__/ +*.pyc + +# Editors +.vscode/ +.idea/ + +# macOS +.DS_Store +""", + encoding="utf-8", + ) + + include_line = ( + f"[include]\n path = {host_gitconfig}\n\n" if host_gitconfig.exists() else "" + ) + + local_gitconfig.write_text( + f"""# Container-local git configuration +{include_line}[core] + excludesfile = {gitignore_global} + +[merge] + conflictstyle = diff3 + +[diff] + colorMoved = default +""", + encoding="utf-8", + ) + + +def main() -> None: + print("[post_install] configuring devcontainer...", file=sys.stderr) + ensure_history_files() + fix_directory_ownership() + setup_git_config() + print("[post_install] complete", file=sys.stderr) + + +if __name__ == "__main__": + main() From be13f03c396b54b85b858bd023bf930b06164e33 Mon Sep 17 00:00:00 2001 From: ningyi-oai Date: Sat, 11 Apr 2026 00:23:50 -0700 Subject: [PATCH 3/9] Pass turn id with feedback uploads (#17314) ## Summary - Add an optional `tags` dictionary to feedback upload params. - Capture the active app-server turn id in the TUI and submit it as `tags.turn_id` with `/feedback` uploads. - Merge client-provided feedback tags into Sentry feedback tags while preserving reserved system fields like `thread_id`, `classification`, `cli_version`, `session_source`, and `reason`. ## Behavior / impact Existing feedback upload callers remain compatible because `tags` is optional and nullable. The wire shape is still a normal JSON object / TypeScript dictionary, so adding future feedback metadata will not require a new top-level protocol field each time. This change only adds feedback metadata for Codex CLI/TUI uploads; it does not affect existing pipelines, DAGs, exports, or downstream consumers unless they choose to read the new `turn_id` feedback tag. ## Tests - `cargo fmt -- --config imports_granularity=Item` passed; stable rustfmt warned that `imports_granularity` is nightly-only. - `cargo run -p codex-app-server-protocol --bin write_schema_fixtures` - `cargo test -p codex-feedback upload_tags_include_client_tags_and_preserve_reserved_fields` - `cargo test -p codex-app-server-protocol schema_fixtures_match_generated` - `cargo test -p codex-tui build_feedback_upload_params` - `cargo test -p codex-tui live_app_server_turn_started_sets_feedback_turn_id` - `cargo check -p codex-app-server --tests` - `git diff --check` --------- Co-authored-by: Codex --- .../schema/json/ClientRequest.json | 9 + .../codex_app_server_protocol.schemas.json | 9 + .../codex_app_server_protocol.v2.schemas.json | 9 + .../schema/json/v2/FeedbackUploadParams.json | 9 + .../typescript/v2/FeedbackUploadParams.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 + .../app-server/src/codex_message_processor.rs | 17 +- codex-rs/feedback/src/lib.rs | 201 +++++++++++++----- codex-rs/tui/src/app.rs | 19 +- codex-rs/tui/src/app_event.rs | 1 + codex-rs/tui/src/bottom_pane/feedback_view.rs | 27 ++- codex-rs/tui/src/chatwidget.rs | 12 +- .../tui/src/chatwidget/tests/app_server.rs | 37 ++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + 14 files changed, 290 insertions(+), 65 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4a69b4d8cc..76f7abdfd2 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -647,6 +647,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 95de3343a9..2c575202e8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7479,6 +7479,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 88df6dce4a..56497c786f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4120,6 +4120,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index 47b752b868..07c2098606 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -22,6 +22,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index ebbe3b32d4..86d9de2f0d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e562981f0a..4ce7e9c0bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2242,6 +2242,8 @@ pub struct FeedbackUploadParams { pub include_logs: bool, #[ts(optional = nullable)] pub extra_log_files: Option>, + #[ts(optional = nullable)] + pub tags: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 530bc7093d..53f4a876c2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -243,6 +243,7 @@ use codex_features::FEATURES; use codex_features::Feature; use codex_features::Stage; use codex_feedback::CodexFeedback; +use codex_feedback::FeedbackUploadOptions; use codex_git_utils::git_diff_to_remote; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManager; @@ -7726,6 +7727,7 @@ impl CodexMessageProcessor { thread_id, include_logs, extra_log_files, + tags, } = params; let conversation_id = match thread_id.as_deref() { @@ -7853,14 +7855,15 @@ impl CodexMessageProcessor { let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { - snapshot.upload_feedback( - &classification, - reason.as_deref(), + snapshot.upload_feedback(FeedbackUploadOptions { + classification: &classification, + reason: reason.as_deref(), + tags: tags.as_ref(), include_logs, - &attachment_paths, - Some(session_source), - sqlite_feedback_logs, - ) + extra_attachment_paths: &attachment_paths, + session_source: Some(session_source), + logs_override: sqlite_feedback_logs, + }) }) .await; diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 712aeee219..ff6b796358 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -338,6 +338,16 @@ pub struct FeedbackSnapshot { pub thread_id: String, } +pub struct FeedbackUploadOptions<'a> { + pub classification: &'a str, + pub reason: Option<&'a str>, + pub tags: Option<&'a BTreeMap>, + pub include_logs: bool, + pub extra_attachment_paths: &'a [PathBuf], + pub session_source: Option, + pub logs_override: Option>, +} + impl FeedbackSnapshot { pub(crate) fn as_bytes(&self) -> &[u8] { &self.bytes @@ -369,16 +379,7 @@ impl FeedbackSnapshot { } /// Upload feedback to Sentry with optional attachments. - pub fn upload_feedback( - &self, - classification: &str, - reason: Option<&str>, - include_logs: bool, - extra_attachment_paths: &[PathBuf], - session_source: Option, - logs_override: Option>, - ) -> Result<()> { - use std::collections::BTreeMap; + pub fn upload_feedback(&self, options: FeedbackUploadOptions<'_>) -> Result<()> { use std::str::FromStr; use std::sync::Arc; @@ -398,13 +399,70 @@ impl FeedbackSnapshot { ..Default::default() }); + let tags = self.upload_tags( + options.classification, + options.reason, + options.tags, + options.session_source.as_ref(), + ); + + let level = match options.classification { + "bug" | "bad_result" | "safety_check" => Level::Error, + _ => Level::Info, + }; + + let mut envelope = Envelope::new(); + let title = format!( + "[{}]: Codex session {}", + display_classification(options.classification), + self.thread_id + ); + + let mut event = Event { + level, + message: Some(title.clone()), + tags, + ..Default::default() + }; + if let Some(r) = options.reason { + use sentry::protocol::Exception; + use sentry::protocol::Values; + + event.exception = Values::from(vec![Exception { + ty: title, + value: Some(r.to_string()), + ..Default::default() + }]); + } + envelope.add_item(EnvelopeItem::Event(event)); + + for attachment in self.feedback_attachments( + options.include_logs, + options.extra_attachment_paths, + options.logs_override, + ) { + envelope.add_item(EnvelopeItem::Attachment(attachment)); + } + + client.send_envelope(envelope); + client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); + Ok(()) + } + + fn upload_tags( + &self, + classification: &str, + reason: Option<&str>, + client_tags: Option<&BTreeMap>, + session_source: Option<&SessionSource>, + ) -> BTreeMap { let cli_version = env!("CARGO_PKG_VERSION"); let mut tags = BTreeMap::from([ (String::from("thread_id"), self.thread_id.to_string()), (String::from("classification"), classification.to_string()), (String::from("cli_version"), cli_version.to_string()), ]); - if let Some(source) = session_source.as_ref() { + if let Some(source) = session_source { tags.insert(String::from("session_source"), source.to_string()); } if let Some(r) = reason { @@ -418,6 +476,16 @@ impl FeedbackSnapshot { "session_source", "reason", ]; + if let Some(client_tags) = client_tags { + for (key, value) in client_tags { + if reserved.contains(&key.as_str()) { + continue; + } + if let Entry::Vacant(entry) = tags.entry(key.clone()) { + entry.insert(value.clone()); + } + } + } for (key, value) in &self.tags { if reserved.contains(&key.as_str()) { continue; @@ -427,45 +495,7 @@ impl FeedbackSnapshot { } } - let level = match classification { - "bug" | "bad_result" | "safety_check" => Level::Error, - _ => Level::Info, - }; - - let mut envelope = Envelope::new(); - let title = format!( - "[{}]: Codex session {}", - display_classification(classification), - self.thread_id - ); - - let mut event = Event { - level, - message: Some(title.clone()), - tags, - ..Default::default() - }; - if let Some(r) = reason { - use sentry::protocol::Exception; - use sentry::protocol::Values; - - event.exception = Values::from(vec![Exception { - ty: title, - value: Some(r.to_string()), - ..Default::default() - }]); - } - envelope.add_item(EnvelopeItem::Event(event)); - - for attachment in - self.feedback_attachments(include_logs, extra_attachment_paths, logs_override) - { - envelope.add_item(EnvelopeItem::Attachment(attachment)); - } - - client.send_envelope(envelope); - client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); - Ok(()) + tags } fn feedback_attachments( @@ -697,4 +727,75 @@ mod tests { assert_eq!(attachments_without_diagnostics[0].buffer, vec![1]); fs::remove_file(extra_path).expect("extra attachment should be removed"); } + + #[test] + fn upload_tags_include_client_tags_and_preserve_reserved_fields() { + let mut tags = BTreeMap::new(); + tags.insert("thread_id".to_string(), "wrong-thread".to_string()); + tags.insert("turn_id".to_string(), "wrong-turn".to_string()); + tags.insert( + "classification".to_string(), + "wrong-classification".to_string(), + ); + tags.insert("cli_version".to_string(), "wrong-version".to_string()); + tags.insert("session_source".to_string(), "wrong-source".to_string()); + tags.insert("reason".to_string(), "wrong-reason".to_string()); + tags.insert("model".to_string(), "gpt-5".to_string()); + let snapshot = FeedbackSnapshot { + bytes: Vec::new(), + tags, + feedback_diagnostics: FeedbackDiagnostics::default(), + thread_id: "thread-123".to_string(), + }; + let mut client_tags = BTreeMap::new(); + client_tags.insert("thread_id".to_string(), "wrong-client-thread".to_string()); + client_tags.insert("turn_id".to_string(), "turn-456".to_string()); + client_tags.insert( + "classification".to_string(), + "wrong-client-classification".to_string(), + ); + client_tags.insert( + "cli_version".to_string(), + "wrong-client-version".to_string(), + ); + client_tags.insert( + "session_source".to_string(), + "wrong-client-source".to_string(), + ); + client_tags.insert("reason".to_string(), "wrong-client-reason".to_string()); + client_tags.insert("client_tag".to_string(), "from-client".to_string()); + + let upload_tags = snapshot.upload_tags( + "bug", + Some("actual reason"), + Some(&client_tags), + Some(&SessionSource::Cli), + ); + + assert_eq!( + upload_tags.get("thread_id").map(String::as_str), + Some("thread-123") + ); + assert_eq!( + upload_tags.get("turn_id").map(String::as_str), + Some("turn-456") + ); + assert_eq!( + upload_tags.get("classification").map(String::as_str), + Some("bug") + ); + assert_eq!( + upload_tags.get("session_source").map(String::as_str), + Some("cli") + ); + assert_eq!( + upload_tags.get("reason").map(String::as_str), + Some("actual reason") + ); + assert_eq!( + upload_tags.get("client_tag").map(String::as_str), + Some("from-client") + ); + assert_eq!(upload_tags.get("model").map(String::as_str), Some("gpt-5")); + } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89280290bc..dd0827822d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2020,6 +2020,7 @@ impl App { app_server: &AppServerSession, category: FeedbackCategory, reason: Option, + turn_id: Option, include_logs: bool, ) { let request_handle = app_server.request_handle(); @@ -2035,6 +2036,7 @@ impl App { rollout_path, category, reason, + turn_id, include_logs, ); tokio::spawn(async move { @@ -4630,9 +4632,10 @@ impl App { AppEvent::SubmitFeedback { category, reason, + turn_id, include_logs, } => { - self.submit_feedback(app_server, category, reason, include_logs); + self.submit_feedback(app_server, category, reason, turn_id, include_logs); } AppEvent::FeedbackSubmitted { origin_thread_id, @@ -6249,6 +6252,7 @@ fn build_feedback_upload_params( rollout_path: Option, category: FeedbackCategory, reason: Option, + turn_id: Option, include_logs: bool, ) -> FeedbackUploadParams { let extra_log_files = if include_logs { @@ -6256,12 +6260,14 @@ fn build_feedback_upload_params( } else { None }; + let tags = turn_id.map(|turn_id| BTreeMap::from([(String::from("turn_id"), turn_id)])); FeedbackUploadParams { classification: crate::bottom_pane::feedback_classification(category).to_string(), reason, thread_id: origin_thread_id.map(|thread_id| thread_id.to_string()), include_logs, extra_log_files, + tags, } } @@ -9644,12 +9650,21 @@ guardian_approval = true Some(rollout_path.clone()), FeedbackCategory::SafetyCheck, Some("needs follow-up".to_string()), + Some("turn-123".to_string()), /*include_logs*/ true, ); assert_eq!(params.classification, "safety_check"); assert_eq!(params.reason, Some("needs follow-up".to_string())); assert_eq!(params.thread_id, Some(thread_id.to_string())); + assert_eq!( + params + .tags + .as_ref() + .and_then(|tags| tags.get("turn_id")) + .map(String::as_str), + Some("turn-123") + ); assert_eq!(params.include_logs, true); assert_eq!(params.extra_log_files, Some(vec![rollout_path])); } @@ -9661,12 +9676,14 @@ guardian_approval = true Some(PathBuf::from("/tmp/rollout.jsonl")), FeedbackCategory::GoodResult, /*reason*/ None, + /*turn_id*/ None, /*include_logs*/ false, ); assert_eq!(params.classification, "good_result"); assert_eq!(params.reason, None); assert_eq!(params.thread_id, None); + assert_eq!(params.tags, None); assert_eq!(params.include_logs, false); assert_eq!(params.extra_log_files, None); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 78a2525350..0e71e1a432 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -559,6 +559,7 @@ pub(crate) enum AppEvent { SubmitFeedback { category: FeedbackCategory, reason: Option, + turn_id: Option, include_logs: bool, }, diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index b1889abca0..f40a21cdfc 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -45,6 +45,7 @@ pub(crate) enum FeedbackAudience { /// through the app-server-managed feedback flow. pub(crate) struct FeedbackNoteView { category: FeedbackCategory, + turn_id: Option, app_event_tx: AppEventSender, include_logs: bool, @@ -57,11 +58,13 @@ pub(crate) struct FeedbackNoteView { impl FeedbackNoteView { pub(crate) fn new( category: FeedbackCategory, + turn_id: Option, app_event_tx: AppEventSender, include_logs: bool, ) -> Self { Self { category, + turn_id, app_event_tx, include_logs, textarea: TextArea::new(), @@ -76,6 +79,7 @@ impl FeedbackNoteView { self.app_event_tx.send(AppEvent::SubmitFeedback { category: self.category, reason, + turn_id: self.turn_id.clone(), include_logs: self.include_logs, }); self.complete = true; @@ -607,7 +611,9 @@ mod tests { fn make_view(category: FeedbackCategory) -> FeedbackNoteView { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - FeedbackNoteView::new(category, tx, /*include_logs*/ true) + FeedbackNoteView::new( + category, /*turn_id*/ None, tx, /*include_logs*/ true, + ) } #[test] @@ -649,7 +655,12 @@ mod tests { fn feedback_view_with_connectivity_diagnostics() { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = FeedbackNoteView::new(FeedbackCategory::Bug, tx, /*include_logs*/ false); + let view = FeedbackNoteView::new( + FeedbackCategory::Bug, + /*turn_id*/ None, + tx, + /*include_logs*/ false, + ); let rendered = render(&view, /*width*/ 60); insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered); @@ -659,7 +670,12 @@ mod tests { fn submit_feedback_emits_submit_event_with_trimmed_note() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = FeedbackNoteView::new(FeedbackCategory::Bug, tx, /*include_logs*/ true); + let mut view = FeedbackNoteView::new( + FeedbackCategory::Bug, + Some("turn-123".to_string()), + tx, + /*include_logs*/ true, + ); view.textarea.insert_str(" something broke "); view.submit(); @@ -670,8 +686,9 @@ mod tests { AppEvent::SubmitFeedback { category: FeedbackCategory::Bug, reason: Some(reason), + turn_id: Some(turn_id), include_logs: true, - } if reason == "something broke" + } if reason == "something broke" && turn_id == "turn-123" )); assert_eq!(view.is_complete(), true); } @@ -682,6 +699,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut view = FeedbackNoteView::new( FeedbackCategory::GoodResult, + /*turn_id*/ None, tx, /*include_logs*/ false, ); @@ -694,6 +712,7 @@ mod tests { AppEvent::SubmitFeedback { category: FeedbackCategory::GoodResult, reason: None, + turn_id: None, include_logs: false, } )); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dfbe10fed9..b8b5d90275 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -854,6 +854,7 @@ pub(crate) struct ChatWidget { pending_status_indicator_restore: bool, suppress_queue_autosend: bool, thread_id: Option, + last_turn_id: Option, thread_name: Option, forked_from: Option, frame_requester: FrameRequester, @@ -1961,6 +1962,7 @@ impl ChatWidget { self.set_skills(/*skills*/ None); self.session_network_proxy = event.network_proxy.clone(); self.thread_id = Some(event.session_id); + self.last_turn_id = None; self.thread_name = event.thread_name.clone(); self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); @@ -2143,6 +2145,7 @@ impl ChatWidget { ) { let view = crate::bottom_pane::FeedbackNoteView::new( category, + self.last_turn_id.clone(), self.app_event_tx.clone(), include_logs, ); @@ -4827,6 +4830,7 @@ impl ChatWidget { pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + last_turn_id: None, thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), @@ -6501,7 +6505,8 @@ impl ChatWidget { } } } - ServerNotification::TurnStarted(_) => { + ServerNotification::TurnStarted(notification) => { + self.last_turn_id = Some(notification.turn.id); self.last_non_retry_error = None; if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { self.on_task_started(); @@ -7072,8 +7077,11 @@ impl ChatWidget { } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TurnStarted(event) => { + let turn_id = event.turn_id; + let model_context_window = event.model_context_window; + self.last_turn_id = Some(turn_id); if !is_resume_initial_replay { - self.apply_turn_started_context_window(event.model_context_window); + self.apply_turn_started_context_window(model_context_window); self.on_task_started(); } } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 09bc9a7013..6b18e2459d 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -147,6 +147,43 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( assert!(chat.bottom_pane.status_widget().is_none()); } +#[tokio::test] +async fn live_app_server_turn_started_sets_feedback_turn_id() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, + }, + }), + /*replay_kind*/ None, + ); + + chat.open_feedback_note( + crate::app_event::FeedbackCategory::Bug, + /*include_logs*/ false, + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SubmitFeedback { + category: crate::app_event::FeedbackCategory::Bug, + reason: None, + turn_id: Some(turn_id), + include_logs: false, + }) if turn_id == "turn-1" + ); +} + #[tokio::test] async fn live_app_server_file_change_item_started_preserves_changes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d99c304fc2..d5c6a5e3b4 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -244,6 +244,7 @@ pub(super) async fn make_chatwidget_manual( pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + last_turn_id: None, thread_name: None, forked_from: None, frame_requester: FrameRequester::test_dummy(), From 0bdeab330b378fa3df4296d3354c4dbc821eecca Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 11 Apr 2026 12:40:08 -0300 Subject: [PATCH 4/9] fix(tui): recall accepted slash commands locally (#17336) # TL;DR - Adds recognized slash commands to the TUI's local in-session recall history. - This is the MVP of the whole feature: it keeps slash-command recall local only: nothing is written to persistent history, app-server history, or core history storage. - Treats slash commands like submitted text once they parse as a known built-in command, regardless of whether command dispatch later succeeds. # Problem Slash commands are handled outside the normal message submission path, so they could clear the composer without becoming part of the local Up-arrow recall list. That made command-heavy workflows awkward: after running `/diff`, `/rename Better title`, `/plan investigate this`, or even a valid command that reports a usage error, users had to retype the command instead of recalling and editing it like a normal prompt. The goal of this PR is to make slash commands feel like submitted input inside the current TUI session while keeping the change deliberately local. This is not persistent history yet; it only affects the composer's in-memory recall behavior. # Mental model The composer owns draft state and local recall. When slash input parses as a recognized built-in command, the composer stages the submitted command text before returning `InputResult::Command` or `InputResult::CommandWithArgs`. `ChatWidget` then dispatches the command and records the staged entry once dispatch returns to the input-result path. Command-name recognition is the only validation before local recall. A valid slash command is recallable whether it succeeds, fails with a usage error, no-ops, is unavailable while a task is running, or is skipped by command-specific logic. An unrecognized slash command is different: it is restored as a draft, surfaces the existing unrecognized-command message, and is not added to recall. Bare commands recalled from typed text use the trimmed submitted draft. Commands selected from the popup record the canonical command text, such as `/diff`, rather than the partial filter text the user typed. Inline commands with arguments keep the original command invocation available locally even when their arguments are later prepared through the normal submission pipeline. # Non-goals Persisting slash commands across sessions is intentionally out of scope. This change does not modify app-server history, core history storage, protocol events, or message submission semantics. This does not change command availability, command side effects, popup filtering, command parsing, or the semantics of unsupported commands. It only changes whether recognized slash-command invocations are available through local Up-arrow recall after the user submits them. # Tradeoffs The main tradeoff is that recall is based on command recognition, not command outcome. This intentionally favors a simpler user model: if the TUI accepted the input as a slash command, the user can recall and edit that input just like plain text. That means valid-but-unsuccessful invocations such as usage errors are recallable, which is useful when the next action is usually to edit and retry. The previous accept/reject design required command dispatch to report a boolean outcome, which made the dispatcher API noisier and forced every branch to decide history behavior. This version keeps the dispatch APIs as side-effect-only methods and localizes history recording to the slash-command input path. Inline command handling still avoids double-recording by preparing inline arguments without using the normal message-submission history path. The staged slash-command entry remains the single local recall record for the command invocation. # Architecture `ChatComposer` stages a pending `HistoryEntry` when recognized slash-command input is promoted into an input result. The pending entry mirrors the existing local history payload shape so recall can restore text elements, local images, remote images, mention bindings, and pending paste state when those are present. `BottomPane` exposes a narrow method for recording that staged command entry because it owns the composer. `ChatWidget` records the staged entry after dispatching a recognized command from the input-result match. Valid commands rejected before they reach `ChatWidget`, such as commands unavailable while a task is running, are staged and recorded in the composer path that detects the rejection. Slash-command dispatch itself now lives in `chatwidget/slash_dispatch.rs` so the behavior is reviewable without adding more weight to `chatwidget.rs`. The extraction is behavior-preserving: the dispatch match arms stay intact, while the input flow in `chatwidget.rs` remains the single place that connects submitted slash-command input to dispatch. # Observability There is no new logging because this is a local UI recall behavior and the result is directly visible through Up-arrow recall. The practical debug path is to trace Enter through `ChatComposer::try_dispatch_bare_slash_command`, `ChatComposer::try_dispatch_slash_command_with_args`, or popup Enter/Tab handling, then confirm the recognized command is staged before dispatch and recorded exactly once afterward. If a valid command unexpectedly does not appear in recall, check whether the input path staged slash history before clearing the composer and whether it used the `ChatWidget` slash-dispatch wrapper. If an unrecognized command unexpectedly appears in recall, check the parser branch that should restore the draft instead of staging history. # Tests Composer-level tests cover staging and recording for a bare typed slash command, a popup-selected command, and an inline command with arguments. Chat-widget tests cover valid commands being recallable after normal dispatch, inline dispatch, usage errors, task-running unavailability, no-op stub dispatch, and command-specific skip behavior such as `/init` when an instructions file already exists. They also cover the negative case: unrecognized slash commands are not added to local recall. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 165 +++++- codex-rs/tui/src/bottom_pane/mod.rs | 8 + codex-rs/tui/src/chatwidget.rs | 471 +--------------- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 519 ++++++++++++++++++ .../src/chatwidget/tests/slash_commands.rs | 120 +++- 5 files changed, 817 insertions(+), 466 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/slash_dispatch.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 403a499bd5..0a82f7cf80 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -29,6 +29,10 @@ //! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like //! history traversal semantics instead of dropping to column 0. //! +//! Slash commands are staged for local history instead of being recorded immediately. Command +//! recall is a two-phase handoff: stage the submitted slash text here, then record it after +//! `ChatWidget` dispatches the command. +//! //! # Submission and Prompt Expansion //! //! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is @@ -228,7 +232,16 @@ pub enum InputResult { text: String, text_elements: Vec, }, + /// A bare slash command parsed by the composer. + /// + /// Callers that dispatch this variant are also responsible for resolving any pending local + /// command-history entry that the composer staged before clearing the visible input. Command(SlashCommand), + /// An inline slash command and its trimmed argument text. + /// + /// The `TextElement` ranges are rebased into the argument string, while any pending local + /// command-history entry still represents the original command invocation that should be + /// committed only if dispatch accepts it. CommandWithArgs(SlashCommand, String, Vec), None, } @@ -311,6 +324,11 @@ pub(crate) struct ChatComposer { /// Tracks keyboard selection for the remote-image rows so Up/Down + Delete/Backspace /// can highlight and remove remote attachments from the composer UI. selected_remote_image_index: Option, + /// Slash-command draft staged for local recall after application-level dispatch. + /// + /// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can + /// prepare their argument text without also double-recording the full command invocation. + pending_slash_command_history: Option, footer_flash: Option, context_window_percent: Option, // Monotonically increasing identifier for textarea elements we insert. @@ -434,6 +452,7 @@ impl ChatComposer { footer_hint_override: None, remote_image_urls: Vec::new(), selected_remote_image_index: None, + pending_slash_command_history: None, footer_flash: None, context_window_percent: None, #[cfg(not(target_os = "linux"))] @@ -1063,6 +1082,16 @@ impl ChatComposer { std::mem::take(&mut self.recent_submission_mention_bindings) } + /// Commit the staged slash-command draft to local Up-arrow recall. + /// + /// Call this after command dispatch. Calling it more than once is harmless because the pending + /// slot is consumed on the first call. + pub(crate) fn record_pending_slash_command_history(&mut self) { + if let Some(entry) = self.pending_slash_command_history.take() { + self.history.record_local_submission(entry); + } + } + fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) { if self.attached_images.is_empty() { return; @@ -1281,6 +1310,7 @@ impl ChatComposer { if let Some(sel) = popup.selected_item() { let CommandItem::Builtin(cmd) = sel; if cmd == SlashCommand::Skills { + self.stage_selected_slash_command_history(cmd); self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } @@ -1305,6 +1335,7 @@ impl ChatComposer { } => { if let Some(sel) = popup.selected_item() { let CommandItem::Builtin(cmd) = sel; + self.stage_selected_slash_command_history(cmd); self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } @@ -2272,8 +2303,11 @@ impl ChatComposer { slash_commands::find_builtin_command(name, self.builtin_command_flags()) { if self.reject_slash_command_if_unavailable(cmd) { + self.stage_slash_command_history(); + self.record_pending_slash_command_history(); return Some(InputResult::None); } + self.stage_slash_command_history(); self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { @@ -2303,9 +2337,13 @@ impl ChatComposer { return None; } if self.reject_slash_command_if_unavailable(cmd) { + self.stage_slash_command_history(); + self.record_pending_slash_command_history(); return Some(InputResult::None); } + self.stage_slash_command_history(); + let mut args_elements = Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); let trimmed_rest = rest.trim(); @@ -2320,9 +2358,13 @@ impl ChatComposer { /// Expand pending placeholders and extract normalized inline-command args. /// /// Inline-arg commands are initially dispatched using the raw draft so command rejection does - /// not consume user input. Once a command is accepted, this helper performs the usual + /// not consume user input. Once a command needs its args, this helper performs the usual /// submission preparation (paste expansion, element trimming) and rebases element ranges from /// full-text offsets to command-arg offsets. + /// + /// Callers that already staged slash-command history should normally pass `false` for + /// `record_history`; otherwise a command such as `/plan investigate` would be entered into + /// local recall through both the slash-command path and the message-submission path. pub(crate) fn prepare_inline_args_submission( &mut self, record_history: bool, @@ -2353,6 +2395,43 @@ impl ChatComposer { true } + /// Stage the current slash-command text for later local recall. + /// + /// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget` + /// commits the staged entry after dispatch so command recall follows the submitted text, not + /// the command outcome. + fn stage_slash_command_history(&mut self) { + self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); + } + + /// Stage a popup-selected command using its canonical command text. + /// + /// Popup filtering text can be partial, so recording the selected command avoids recalling + /// `/di` after the user actually accepted `/diff`. + fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) { + self.stage_slash_command_history_text(format!("/{}", cmd.command())); + } + + /// Store the provided command text and the current composer adornments in the pending slot. + /// + /// The pending entry intentionally has the same shape as other local history entries so recall + /// can rehydrate attachments, mention bindings, and pending paste placeholders if command + /// workflows start carrying those through in the future. + fn stage_slash_command_history_text(&mut self, text: String) { + self.pending_slash_command_history = Some(HistoryEntry { + text, + text_elements: self.textarea.text_elements(), + local_image_paths: self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect(), + remote_image_urls: self.remote_image_urls.clone(), + mention_bindings: self.snapshot_mention_bindings(), + pending_pastes: self.pending_pastes.clone(), + }); + } + /// Translate full-text element ranges into command-argument ranges. /// /// `rest_offset` is the byte offset where `rest` begins in the full text. @@ -7863,6 +7942,90 @@ mod tests { ); } + #[test] + fn bare_slash_command_can_be_recalled_after_recording_pending_history() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("/diff".to_string(), Vec::new(), Vec::new()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::Command(SlashCommand::Diff)); + composer.record_pending_slash_command_history(); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert_eq!(composer.current_text(), "/diff"); + } + + #[test] + fn popup_selected_slash_command_records_canonical_command_history() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + composer.set_text_content("/di".to_string(), Vec::new(), Vec::new()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::Command(SlashCommand::Diff)); + composer.record_pending_slash_command_history(); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert_eq!(composer.current_text(), "/diff"); + } + + #[test] + fn inline_slash_command_can_be_recalled_after_recording_pending_history() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_collaboration_modes_enabled(/*enabled*/ true); + + composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new()); + composer.active_popup = ActivePopup::None; + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::CommandWithArgs(cmd, args, text_elements) => { + assert_eq!(cmd, SlashCommand::Plan); + assert_eq!(args, "investigate this"); + assert!(text_elements.is_empty()); + } + other => panic!("expected inline /plan command, got {other:?}"), + } + composer.record_pending_slash_command_history(); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(result, InputResult::None); + assert_eq!(composer.current_text(), "/plan investigate this"); + } + #[test] fn apply_external_edit_rebuilds_text_and_attachments() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ef3898bafd..bd3bd946af 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -280,6 +280,14 @@ impl BottomPane { self.composer.take_recent_submission_mention_bindings() } + /// Add a staged slash-command draft to the composer's local recall list. + /// + /// This should be called exactly once after `ChatWidget` dispatches a recognized command. + /// Slash recall records the submitted command text regardless of whether the command succeeds. + pub(crate) fn record_pending_slash_command_history(&mut self) { + self.composer.record_pending_slash_command_history(); + } + /// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text. pub(crate) fn drain_pending_submission_state(&mut self) { let _ = self.take_recent_submission_images_with_placeholders(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b8b5d90275..1e5ad3e56a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -25,6 +25,10 @@ //! the final answer. During streaming we hide the status row to avoid duplicate //! progress indicators; once commentary completes and stream queues drain, we //! re-show it so users still see turn-in-progress state between output bursts. +//! +//! Slash-command parsing lives in the bottom-pane composer, but slash-command acceptance lives +//! here. That split lets the composer stage a recall entry before clearing input while this module +//! records the attempted slash command after dispatch just like ordinary submitted text. use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; @@ -355,6 +359,7 @@ use self::interrupts::InterruptManager; mod session_header; use self::session_header::SessionHeader; mod skills; +mod slash_dispatch; use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; @@ -5088,10 +5093,10 @@ impl ChatWidget { self.queue_user_message(user_message); } InputResult::Command(cmd) => { - self.dispatch_command(cmd); + self.handle_slash_command_dispatch(cmd); } InputResult::CommandWithArgs(cmd, args, text_elements) => { - self.dispatch_command_with_args(cmd, args, text_elements); + self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); } InputResult::None => {} }, @@ -5197,468 +5202,6 @@ impl ChatWidget { self.last_agent_markdown.as_deref() } - fn dispatch_command(&mut self, cmd: SlashCommand) { - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.bottom_pane.drain_pending_submission_state(); - self.request_redraw(); - return; - } - match cmd { - SlashCommand::Feedback => { - if !self.config.feedback_enabled { - let params = crate::bottom_pane::feedback_disabled_params(); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - return; - } - // Step 1: pick a category (UI built in feedback_view) - let params = - crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - } - SlashCommand::New => { - self.app_event_tx.send(AppEvent::NewSession); - } - SlashCommand::Clear => { - self.app_event_tx.send(AppEvent::ClearUi); - } - SlashCommand::Resume => { - self.app_event_tx.send(AppEvent::OpenResumePicker); - } - SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::ForkCurrentSession); - } - SlashCommand::Init => { - let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); - if init_target.exists() { - let message = format!( - "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." - ); - self.add_info_message(message, /*hint*/ None); - return; - } - const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_user_message(INIT_PROMPT.to_string().into()); - } - SlashCommand::Compact => { - self.clear_token_usage(); - if !self.bottom_pane.is_task_running() { - self.bottom_pane.set_task_running(/*running*/ true); - } - self.app_event_tx.compact(); - } - SlashCommand::Review => { - self.open_review_popup(); - } - SlashCommand::Rename => { - self.session_telemetry - .counter("codex.thread.rename", /*inc*/ 1, &[]); - self.show_rename_prompt(); - } - SlashCommand::Model => { - self.open_model_popup(); - } - SlashCommand::Fast => { - let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { - None - } else { - Some(ServiceTier::Fast) - }; - self.set_service_tier_selection(next_tier); - } - SlashCommand::Realtime => { - if !self.realtime_conversation_enabled() { - return; - } - if self.realtime_conversation.is_live() { - self.stop_realtime_conversation_from_ui(); - } else { - self.start_realtime_conversation(); - } - } - SlashCommand::Settings => { - if !self.realtime_audio_device_selection_enabled() { - return; - } - self.open_realtime_audio_popup(); - } - SlashCommand::Personality => { - self.open_personality_popup(); - } - SlashCommand::Plan => { - if !self.collaboration_modes_enabled() { - self.add_info_message( - "Collaboration modes are disabled.".to_string(), - Some("Enable collaboration modes to use /plan.".to_string()), - ); - return; - } - if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { - self.set_collaboration_mask(mask); - } else { - self.add_info_message( - "Plan mode unavailable right now.".to_string(), - /*hint*/ None, - ); - } - } - SlashCommand::Collab => { - if !self.collaboration_modes_enabled() { - self.add_info_message( - "Collaboration modes are disabled.".to_string(), - Some("Enable collaboration modes to use /collab.".to_string()), - ); - return; - } - self.open_collaboration_modes_popup(); - } - SlashCommand::Agent | SlashCommand::MultiAgents => { - self.app_event_tx.send(AppEvent::OpenAgentPicker); - } - SlashCommand::Approvals => { - self.open_permissions_popup(); - } - SlashCommand::Permissions => { - self.open_permissions_popup(); - } - SlashCommand::ElevateSandbox => { - #[cfg(target_os = "windows")] - { - let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - let windows_degraded_sandbox_enabled = - matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); - if !windows_degraded_sandbox_enabled - || !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - { - // This command should not be visible/recognized outside degraded mode, - // but guard anyway in case something dispatches it directly. - return; - } - - let Some(preset) = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - else { - // Avoid panicking in interactive UI; treat this as a recoverable - // internal error. - self.add_error_message( - "Internal error: missing the 'auto' approval preset.".to_string(), - ); - return; - }; - - if let Err(err) = self - .config - .permissions - .approval_policy - .can_set(&preset.approval) - { - self.add_error_message(err.to_string()); - return; - } - - self.session_telemetry.counter( - "codex.windows_sandbox.setup_elevated_sandbox_command", - /*inc*/ 1, - &[], - ); - self.app_event_tx - .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); - } - #[cfg(not(target_os = "windows"))] - { - let _ = &self.session_telemetry; - // Not supported; on non-Windows this command should never be reachable. - }; - } - SlashCommand::SandboxReadRoot => { - self.add_error_message( - "Usage: /sandbox-add-read-dir ".to_string(), - ); - } - SlashCommand::Experimental => { - self.open_experimental_popup(); - } - SlashCommand::Quit | SlashCommand::Exit => { - self.request_quit_without_confirmation(); - } - SlashCommand::Logout => { - if let Err(e) = codex_login::logout( - &self.config.codex_home, - self.config.cli_auth_credentials_store_mode, - ) { - tracing::error!("failed to logout: {e}"); - } - self.request_quit_without_confirmation(); - } - // SlashCommand::Undo => { - // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); - // } - SlashCommand::Copy => { - self.copy_last_agent_markdown(); - } - SlashCommand::Diff => { - self.add_diff_in_progress(); - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() - } - } - Err(e) => format!("Failed to compute diff: {e}"), - }; - tx.send(AppEvent::DiffResult(text)); - }); - } - SlashCommand::Mention => { - self.insert_str("@"); - } - SlashCommand::Skills => { - self.open_skills_menu(); - } - SlashCommand::Status => { - if self.should_prefetch_rate_limits() { - let request_id = self.next_status_refresh_request_id; - self.next_status_refresh_request_id = - self.next_status_refresh_request_id.wrapping_add(1); - self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); - self.app_event_tx.send(AppEvent::RefreshRateLimits { - origin: RateLimitRefreshOrigin::StatusCommand { request_id }, - }); - } else { - self.add_status_output( - /*refreshing_rate_limits*/ false, /*request_id*/ None, - ); - } - } - SlashCommand::DebugConfig => { - self.add_debug_config_output(); - } - SlashCommand::Title => { - self.open_terminal_title_setup(); - } - SlashCommand::Statusline => { - self.open_status_line_setup(); - } - SlashCommand::Theme => { - self.open_theme_picker(); - } - SlashCommand::Ps => { - self.add_ps_output(); - } - SlashCommand::Stop => { - self.clean_background_terminals(); - } - SlashCommand::MemoryDrop => { - self.add_app_server_stub_message("Memory maintenance"); - } - SlashCommand::MemoryUpdate => { - self.add_app_server_stub_message("Memory maintenance"); - } - SlashCommand::Mcp => { - self.add_mcp_output(); - } - SlashCommand::Apps => { - self.add_connectors_output(); - } - SlashCommand::Plugins => { - self.add_plugins_output(); - } - SlashCommand::Rollout => { - if let Some(path) = self.rollout_path() { - self.add_info_message( - format!("Current rollout path: {}", path.display()), - /*hint*/ None, - ); - } else { - self.add_info_message( - "Rollout path is not available yet.".to_string(), - /*hint*/ None, - ); - } - } - SlashCommand::TestApproval => { - use std::collections::HashMap; - - use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; - use codex_protocol::protocol::FileChange; - - self.on_apply_patch_approval_request( - "1".to_string(), - ApplyPatchApprovalRequestEvent { - call_id: "1".to_string(), - turn_id: "turn-1".to_string(), - changes: HashMap::from([ - ( - PathBuf::from("/tmp/test.txt"), - FileChange::Add { - content: "test".to_string(), - }, - ), - ( - PathBuf::from("/tmp/test2.txt"), - FileChange::Update { - unified_diff: "+test\n-test2".to_string(), - move_path: None, - }, - ), - ]), - reason: None, - grant_root: Some(PathBuf::from("/tmp")), - }, - ); - } - } - } - - fn dispatch_command_with_args( - &mut self, - cmd: SlashCommand, - args: String, - _text_elements: Vec, - ) { - if !cmd.supports_inline_args() { - self.dispatch_command(cmd); - return; - } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - return; - } - - let trimmed = args.trim(); - match cmd { - SlashCommand::Fast => { - if trimmed.is_empty() { - self.dispatch_command(cmd); - return; - } - match trimmed.to_ascii_lowercase().as_str() { - "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), - "off" => self.set_service_tier_selection(/*service_tier*/ None), - "status" => { - let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) - { - "on" - } else { - "off" - }; - self.add_info_message( - format!("Fast mode is {status}."), - /*hint*/ None, - ); - } - _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); - } - } - } - SlashCommand::Rename if !trimmed.is_empty() => { - self.session_telemetry - .counter("codex.thread.rename", /*inc*/ 1, &[]); - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args) - else { - self.add_error_message("Thread name cannot be empty.".to_string()); - return; - }; - self.app_event_tx.set_thread_name(name); - self.bottom_pane.drain_pending_submission_state(); - } - SlashCommand::Plan if !trimmed.is_empty() => { - self.dispatch_command(cmd); - if self.active_mode_kind() != ModeKind::Plan { - return; - } - let Some((prepared_args, prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ true) - else { - return; - }; - let local_images = self - .bottom_pane - .take_recent_submission_images_with_placeholders(); - let remote_image_urls = self.take_remote_image_urls(); - let user_message = UserMessage { - text: prepared_args, - local_images, - remote_image_urls, - text_elements: prepared_elements, - mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), - }; - if self.is_session_configured() { - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.set_status_header(String::from("Working")); - self.submit_user_message(user_message); - } else { - self.queue_user_message(user_message); - } - } - SlashCommand::Review if !trimmed.is_empty() => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - self.submit_op(AppCommand::review(ReviewRequest { - target: ReviewTarget::Custom { - instructions: prepared_args, - }, - user_facing_hint: None, - })); - self.bottom_pane.drain_pending_submission_state(); - } - SlashCommand::Resume if !trimmed.is_empty() => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - self.app_event_tx - .send(AppEvent::ResumeSessionByIdOrName(prepared_args)); - self.bottom_pane.drain_pending_submission_state(); - } - SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - self.app_event_tx - .send(AppEvent::BeginWindowsSandboxGrantReadRoot { - path: prepared_args, - }); - self.bottom_pane.drain_pending_submission_state(); - } - _ => self.dispatch_command(cmd), - } - } - fn show_rename_prompt(&mut self) { let tx = self.app_event_tx.clone(); let has_name = self diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs new file mode 100644 index 0000000000..e6e93d17fb --- /dev/null +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -0,0 +1,519 @@ +//! Slash-command dispatch and local-recall handoff for `ChatWidget`. +//! +//! `ChatComposer` parses slash input and stages recognized command text for local +//! Up-arrow recall before returning an input result. This module owns the app-level +//! dispatch step and records the staged entry once the command has been handled, so +//! slash-command recall follows the same submitted-input rule as ordinary text. + +use super::*; + +impl ChatWidget { + /// Dispatch a bare slash command and record its staged local-history entry. + /// + /// The composer stages history before returning `InputResult::Command`; this wrapper commits + /// that staged entry after dispatch so slash-command recall follows the same "submitted input" + /// rule as normal text. + pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommand) { + self.dispatch_command(cmd); + self.bottom_pane.record_pending_slash_command_history(); + } + + /// Dispatch an inline slash command and record its staged local-history entry. + /// + /// Inline command arguments may later be prepared through the normal submission pipeline, but + /// local command recall still tracks the original command invocation. Treating this wrapper as + /// the only input-result entry point avoids double-recording commands with inline args. + pub(super) fn handle_slash_command_with_args_dispatch( + &mut self, + cmd: SlashCommand, + args: String, + text_elements: Vec, + ) { + self.dispatch_command_with_args(cmd, args, text_elements); + self.bottom_pane.record_pending_slash_command_history(); + } + + fn apply_plan_slash_command(&mut self) -> bool { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /plan.".to_string()), + ); + return false; + } + if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { + self.set_collaboration_mask(mask); + true + } else { + self.add_info_message( + "Plan mode unavailable right now.".to_string(), + /*hint*/ None, + ); + false + } + } + + pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) { + if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.add_to_history(history_cell::new_error_event(message)); + self.bottom_pane.drain_pending_submission_state(); + self.request_redraw(); + return; + } + + match cmd { + SlashCommand::Feedback => { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return; + } + // Step 1: pick a category (UI built in feedback_view) + let params = + crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + SlashCommand::New => { + self.app_event_tx.send(AppEvent::NewSession); + } + SlashCommand::Clear => { + self.app_event_tx.send(AppEvent::ClearUi); + } + SlashCommand::Resume => { + self.app_event_tx.send(AppEvent::OpenResumePicker); + } + SlashCommand::Fork => { + self.app_event_tx.send(AppEvent::ForkCurrentSession); + } + SlashCommand::Init => { + let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + if init_target.exists() { + let message = format!( + "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." + ); + self.add_info_message(message, /*hint*/ None); + return; + } + const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md"); + self.submit_user_message(INIT_PROMPT.to_string().into()); + } + SlashCommand::Compact => { + self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } + self.app_event_tx.compact(); + } + SlashCommand::Review => { + self.open_review_popup(); + } + SlashCommand::Rename => { + self.session_telemetry + .counter("codex.thread.rename", /*inc*/ 1, &[]); + self.show_rename_prompt(); + } + SlashCommand::Model => { + self.open_model_popup(); + } + SlashCommand::Fast => { + let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + None + } else { + Some(ServiceTier::Fast) + }; + self.set_service_tier_selection(next_tier); + } + SlashCommand::Realtime => { + if !self.realtime_conversation_enabled() { + return; + } + if self.realtime_conversation.is_live() { + self.stop_realtime_conversation_from_ui(); + } else { + self.start_realtime_conversation(); + } + } + SlashCommand::Settings => { + if !self.realtime_audio_device_selection_enabled() { + return; + } + self.open_realtime_audio_popup(); + } + SlashCommand::Personality => { + self.open_personality_popup(); + } + SlashCommand::Plan => { + self.apply_plan_slash_command(); + } + SlashCommand::Collab => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /collab.".to_string()), + ); + return; + } + self.open_collaboration_modes_popup(); + } + SlashCommand::Agent | SlashCommand::MultiAgents => { + self.app_event_tx.send(AppEvent::OpenAgentPicker); + } + SlashCommand::Approvals => { + self.open_permissions_popup(); + } + SlashCommand::Permissions => { + self.open_permissions_popup(); + } + SlashCommand::ElevateSandbox => { + #[cfg(target_os = "windows")] + { + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let windows_degraded_sandbox_enabled = + matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); + if !windows_degraded_sandbox_enabled + || !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + { + // This command should not be visible/recognized outside degraded mode, + // but guard anyway in case something dispatches it directly. + return; + } + + let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + else { + // Avoid panicking in interactive UI; treat this as a recoverable + // internal error. + self.add_error_message( + "Internal error: missing the 'auto' approval preset.".to_string(), + ); + return; + }; + + if let Err(err) = self + .config + .permissions + .approval_policy + .can_set(&preset.approval) + { + self.add_error_message(err.to_string()); + return; + } + + self.session_telemetry.counter( + "codex.windows_sandbox.setup_elevated_sandbox_command", + /*inc*/ 1, + &[], + ); + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = &self.session_telemetry; + // Not supported; on non-Windows this command should never be reachable. + } + } + SlashCommand::SandboxReadRoot => { + self.add_error_message( + "Usage: /sandbox-add-read-dir ".to_string(), + ); + } + SlashCommand::Experimental => { + self.open_experimental_popup(); + } + SlashCommand::Quit | SlashCommand::Exit => { + self.request_quit_without_confirmation(); + } + SlashCommand::Logout => { + if let Err(e) = codex_login::logout( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + tracing::error!("failed to logout: {e}"); + } + self.request_quit_without_confirmation(); + } + // SlashCommand::Undo => { + // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); + // } + SlashCommand::Copy => { + self.copy_last_agent_markdown(); + } + SlashCommand::Diff => { + self.add_diff_in_progress(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } + } + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); + } + SlashCommand::Mention => { + self.insert_str("@"); + } + SlashCommand::Skills => { + self.open_skills_menu(); + } + SlashCommand::Status => { + if self.should_prefetch_rate_limits() { + let request_id = self.next_status_refresh_request_id; + self.next_status_refresh_request_id = + self.next_status_refresh_request_id.wrapping_add(1); + self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + self.app_event_tx.send(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + }); + } else { + self.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + } + } + SlashCommand::DebugConfig => { + self.add_debug_config_output(); + } + SlashCommand::Title => { + self.open_terminal_title_setup(); + } + SlashCommand::Statusline => { + self.open_status_line_setup(); + } + SlashCommand::Theme => { + self.open_theme_picker(); + } + SlashCommand::Ps => { + self.add_ps_output(); + } + SlashCommand::Stop => { + self.clean_background_terminals(); + } + SlashCommand::MemoryDrop => { + self.add_app_server_stub_message("Memory maintenance"); + } + SlashCommand::MemoryUpdate => { + self.add_app_server_stub_message("Memory maintenance"); + } + SlashCommand::Mcp => { + self.add_mcp_output(); + } + SlashCommand::Apps => { + self.add_connectors_output(); + } + SlashCommand::Plugins => { + self.add_plugins_output(); + } + SlashCommand::Rollout => { + if let Some(path) = self.rollout_path() { + self.add_info_message( + format!("Current rollout path: {}", path.display()), + /*hint*/ None, + ); + } else { + self.add_info_message( + "Rollout path is not available yet.".to_string(), + /*hint*/ None, + ); + } + } + SlashCommand::TestApproval => { + use std::collections::HashMap; + + use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; + use codex_protocol::protocol::FileChange; + + self.on_apply_patch_approval_request( + "1".to_string(), + ApplyPatchApprovalRequestEvent { + call_id: "1".to_string(), + turn_id: "turn-1".to_string(), + changes: HashMap::from([ + ( + PathBuf::from("/tmp/test.txt"), + FileChange::Add { + content: "test".to_string(), + }, + ), + ( + PathBuf::from("/tmp/test2.txt"), + FileChange::Update { + unified_diff: "+test\n-test2".to_string(), + move_path: None, + }, + ), + ]), + reason: None, + grant_root: Some(PathBuf::from("/tmp")), + }, + ); + } + } + } + + /// Run an inline slash command. + /// + /// Branches that prepare arguments should pass `record_history: false` to the composer because + /// the staged slash-command entry is the recall record; using the normal submission-history + /// path as well would make a single command appear twice during Up-arrow navigation. + pub(super) fn dispatch_command_with_args( + &mut self, + cmd: SlashCommand, + args: String, + _text_elements: Vec, + ) { + if !cmd.supports_inline_args() { + self.dispatch_command(cmd); + return; + } + if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + return; + } + + let trimmed = args.trim(); + match cmd { + SlashCommand::Fast => { + if trimmed.is_empty() { + self.dispatch_command(cmd); + return; + } + let prepared_args = if self.bottom_pane.composer_text().is_empty() { + args + } else { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + prepared_args + }; + match prepared_args.trim().to_ascii_lowercase().as_str() { + "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), + "off" => self.set_service_tier_selection(/*service_tier*/ None), + "status" => { + let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) + { + "on" + } else { + "off" + }; + self.add_info_message( + format!("Fast mode is {status}."), + /*hint*/ None, + ); + } + _ => { + self.add_error_message("Usage: /fast [on|off|status]".to_string()); + } + } + } + SlashCommand::Rename if !trimmed.is_empty() => { + self.session_telemetry + .counter("codex.thread.rename", /*inc*/ 1, &[]); + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args) + else { + self.add_error_message("Thread name cannot be empty.".to_string()); + return; + }; + self.app_event_tx.set_thread_name(name); + self.bottom_pane.drain_pending_submission_state(); + } + SlashCommand::Plan if !trimmed.is_empty() => { + if !self.apply_plan_slash_command() { + return; + } + let Some((prepared_args, prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + let local_images = self + .bottom_pane + .take_recent_submission_images_with_placeholders(); + let remote_image_urls = self.take_remote_image_urls(); + let user_message = UserMessage { + text: prepared_args, + local_images, + remote_image_urls, + text_elements: prepared_elements, + mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), + }; + if self.is_session_configured() { + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + self.submit_user_message(user_message); + } else { + self.queue_user_message(user_message); + } + } + SlashCommand::Review if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.submit_op(AppCommand::review(ReviewRequest { + target: ReviewTarget::Custom { + instructions: prepared_args, + }, + user_facing_hint: None, + })); + self.bottom_pane.drain_pending_submission_state(); + } + SlashCommand::Resume if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.app_event_tx + .send(AppEvent::ResumeSessionByIdOrName(prepared_args)); + self.bottom_pane.drain_pending_submission_state(); + } + SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxGrantReadRoot { + path: prepared_args, + }); + self.bottom_pane.drain_pending_submission_state(); + } + _ => self.dispatch_command(cmd), + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e751292550..2afb959349 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -9,6 +9,20 @@ fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnC .expect("turn complete event should deserialize") } +fn submit_composer_text(chat: &mut ChatWidget, text: &str) { + chat.bottom_pane + .set_composer_text(text.to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); +} + +fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String { + chat.bottom_pane + .set_composer_text(String::new(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.bottom_pane.composer_text() +} + #[tokio::test] async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -63,7 +77,7 @@ async fn slash_init_skips_when_project_doc_exists() { std::fs::write(&existing_path, "existing instructions").unwrap(); chat.config.cwd = tempdir.path().to_path_buf().abs(); - chat.dispatch_command(SlashCommand::Init); + submit_composer_text(&mut chat, "/init"); match op_rx.try_recv() { Err(TryRecvError::Empty) => {} @@ -85,6 +99,110 @@ async fn slash_init_skips_when_project_doc_exists() { std::fs::read_to_string(existing_path).unwrap(), "existing instructions" ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/init"); +} + +#[tokio::test] +async fn bare_slash_command_is_available_from_local_recall_after_dispatch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/diff"); + + let _ = drain_insert_history(&mut rx); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "/diff"); +} + +#[tokio::test] +async fn inline_slash_command_is_available_from_local_recall_after_dispatch() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/rename Better title"); + + let _ = drain_insert_history(&mut rx); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "/rename Better title"); +} + +#[tokio::test] +async fn usage_error_slash_command_is_available_from_local_recall() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + + submit_composer_text(&mut chat, "/fast maybe"); + + assert_eq!(chat.bottom_pane.composer_text(), ""); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Usage: /fast [on|off|status]"), + "expected usage message, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/fast maybe"); +} + +#[tokio::test] +async fn unrecognized_slash_command_is_not_added_to_local_recall() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/does-not-exist"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Unrecognized command '/does-not-exist'"), + "expected unrecognized-command message, got: {rendered:?}" + ); + assert_eq!(chat.bottom_pane.composer_text(), "/does-not-exist"); + assert_eq!(recall_latest_after_clearing(&mut chat), ""); +} + +#[tokio::test] +async fn unavailable_slash_command_is_available_from_local_recall() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + + submit_composer_text(&mut chat, "/model"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("'/model' is disabled while a task is in progress."), + "expected disabled-command message, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/model"); +} + +#[tokio::test] +async fn no_op_stub_slash_command_is_available_from_local_recall() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/debug-m-drop"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Memory maintenance"), + "expected stub message, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/debug-m-drop"); } #[tokio::test] From 51d58c56d53047760a082e694b9c9452f8cc6b6a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 11 Apr 2026 09:02:05 -0700 Subject: [PATCH 5/9] Handle closed TUI input stream as shutdown (#17430) Addresses #17276 Problem: Closing the terminal while the TUI input stream is pending could leave the app outside the normal shutdown path, which is risky when an approval prompt is active. Solution: Treat a closed TUI input stream as ShutdownFirst so existing thread shutdown behavior cancels pending work and approvals before exit. --- codex-rs/tui/src/app.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dd0827822d..7dea884dea 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3940,10 +3940,15 @@ impl App { } AppRunControl::Continue } - Some(event) = tui_events.next() => { - match app.handle_tui_event(tui, &mut app_server, event).await { - Ok(control) => control, - Err(err) => break Err(err), + event = tui_events.next() => { + if let Some(event) = event { + match app.handle_tui_event(tui, &mut app_server, event).await { + Ok(control) => control, + Err(err) => break Err(err), + } + } else { + tracing::warn!("terminal input stream closed; shutting down active thread"); + app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await } } app_server_event = app_server.next_event(), if listen_for_app_server_events => { From 39cc85310fbb1c4d04034e596cd7420090875799 Mon Sep 17 00:00:00 2001 From: Adrian <145513011+adrian-openai@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:52:06 -0700 Subject: [PATCH 6/9] Add use_agent_identity feature flag (#17385) --- codex-rs/app-server/tests/suite/v2/account.rs | 4 +++- codex-rs/core/config.schema.json | 6 ++++++ .../exec-server/src/server/handler/tests.rs | 3 ++- codex-rs/features/src/lib.rs | 8 ++++++++ codex-rs/features/src/tests.rs | 18 ++++++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 3c88bcb7a4..f798122635 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -45,7 +45,9 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +// Account tests spin up fresh app-server processes repeatedly, which can take +// longer on slower Bazel macOS runners once the suite is already warm. +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; // Helper to create a minimal config.toml for the app server diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3fba1d7217..bd74c46595 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -503,6 +503,9 @@ "unified_exec": { "type": "boolean" }, + "use_agent_identity": { + "type": "boolean" + }, "use_legacy_landlock": { "type": "boolean" }, @@ -2349,6 +2352,9 @@ "unified_exec": { "type": "boolean" }, + "use_agent_identity": { + "type": "boolean" + }, "use_legacy_landlock": { "type": "boolean" }, diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 17867bbb04..c0b20172ca 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -275,7 +275,8 @@ async fn output_and_exit_are_retained_after_notification_receiver_closes() { process_id.as_str(), shell_argv( "sleep 0.05; printf 'first\\n'; sleep 0.05; printf 'second\\n'", - "echo first&& ping -n 2 127.0.0.1 >NUL&& echo second", + // `cmd.exe` retains the space before `&&` in `echo first && ...`. + "(echo first) && ping -n 2 127.0.0.1 >NUL && (echo second)", ), )) .await diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 590a1ff69f..10d5fdf884 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -186,6 +186,8 @@ pub enum Feature { ResponsesWebsockets, /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. ResponsesWebsocketsV2, + /// Use the agent identity registration flow for ChatGPT-authenticated sessions. + UseAgentIdentity, } impl Feature { @@ -909,6 +911,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::UseAgentIdentity, + key: "use_agent_identity", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, ]; pub fn unstable_features_warning_event( diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 40009dd884..bc357a7fb4 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -163,6 +163,24 @@ fn remote_control_is_under_development() { assert_eq!(Feature::RemoteControl.default_enabled(), false); } +#[test] +fn use_agent_identity_is_under_development() { + assert_eq!(Feature::UseAgentIdentity.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::UseAgentIdentity.default_enabled(), false); +} + +#[test] +fn image_detail_original_feature_is_experimental_and_user_toggleable() { + let stage = Feature::ImageDetailOriginal.stage(); + + assert!(matches!(stage, Stage::Experimental { .. })); + assert_eq!( + stage.experimental_menu_name(), + Some("Original image detail") + ); + assert_eq!(Feature::ImageDetailOriginal.default_enabled(), false); +} + #[test] fn collab_is_legacy_alias_for_multi_agent() { assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); From 640d3a036fcc0547959c6cad3f5cf2cc481a1119 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 11 Apr 2026 11:55:14 -0700 Subject: [PATCH 7/9] Update issue labeler agent labels (#17483) Problem: The automatic issue labeler still treated agent-related issues as one broad category, even though more specific agent-area labels now exist. Solution: Update the issue labeler prompt to prefer the new agent-area labels and keep "agent" as the fallback for uncategorized core agent issues. --- .github/workflows/issue-labeler.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 1173685890..80ef91ccb8 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -44,6 +44,7 @@ jobs: 6. iOS — Issues with the Codex iOS app. - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. + - For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). 2. mcp — Topics involving Model Context Protocol servers/clients. 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. @@ -61,6 +62,13 @@ jobs: 15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. 16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. 17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. + 18. app-server - Issues involving the app-server protocol or interfaces, including SDK/API payloads, thread/* and turn/* RPCs, app-server launch behavior, external app/controller bridges, and app-server protocol/schema behavior. + 19. connectivity - Network connectivity or endpoint issues, including reconnecting messages, stream dropped/disconnected errors, websocket/SSE/transport failures, timeout/network/VPN/proxy/API endpoint failures, and related retry behavior. + 20. subagent - Issues involving subagents, sub-agents, or multi-agent behavior, including spawn_agent, wait_agent, close_agent, worker/explorer roles, delegation, agent teams, lifecycle, model/config inheritance, quotas, and orchestration. + 21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state. + 22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior. + 23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior. + 24. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, or plan. Issue number: ${{ github.event.issue.number }} From 163ae7d3e6578c69afcf4ba80353bc76c86a683b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 11 Apr 2026 13:52:17 -0700 Subject: [PATCH 8/9] fix (#17493) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/core/src/realtime_prompt.rs | 2 +- .../core/templates/realtime/backend_prompt.md | 30 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/realtime_prompt.rs b/codex-rs/core/src/realtime_prompt.rs index d2b7570669..27005c630d 100644 --- a/codex-rs/core/src/realtime_prompt.rs +++ b/codex-rs/core/src/realtime_prompt.rs @@ -74,7 +74,7 @@ mod tests { let prompt = prepare_realtime_backend_prompt(/*prompt*/ None, /*config_prompt*/ None); - assert!(prompt.starts_with("You are **Codex**")); + assert!(prompt.starts_with("You are Codex, an OpenAI Coding Agent")); assert!(prompt.contains("The user's name is ")); assert!(!prompt.contains("{{ user_first_name }}")); } diff --git a/codex-rs/core/templates/realtime/backend_prompt.md b/codex-rs/core/templates/realtime/backend_prompt.md index c5a98b03c5..c45803ec7d 100644 --- a/codex-rs/core/templates/realtime/backend_prompt.md +++ b/codex-rs/core/templates/realtime/backend_prompt.md @@ -1,6 +1,12 @@ -You are **Codex**, an **OpenAI Coding Agent**: a real-time, voice-friendly coding assistant that helps the user while they work in the **current repository/project**. +You are Codex, an OpenAI Coding Agent — a real-time, voice-friendly assistant helping the user in their current repository/project. -The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occasionally (not in every reply), mainly for emphasis, confirmations, or polite transitions. +Be concise, clear, and efficient. Keep responses tight and useful—no fluff. + +Your personality is a playful dev buddy: super fun, warm, witty, and expressive. Bring energy and personality to every response—light humor, friendly vibes, and a "we've got this" attitude—without getting in the way of getting things done. + +The user's name is {{ user_first_name }}. Use it sparingly—only for emphasis, confirmations, or smooth transitions. + +Talk like a trusted collaborator and a friend. Keep things natural, supportive, and easy to follow. ## Core role @@ -9,7 +15,6 @@ The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occas ## Communication style (voice-friendly) -* Start every response with **one short acknowledgement sentence** that mirrors the user's request. * Be specific and concrete: prefer exact filenames, commands, diffs, and step-by-step actions over vague advice. * Keep responses concise by default. Use bullets and short paragraphs. * Ask clarifying questions only when necessary to avoid doing the wrong work. Otherwise, make a reasonable assumption and state it. @@ -20,7 +25,8 @@ The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occas * Usually, when {{ user_first_name }} asks you to do something, they are asking you to delegate work to the backend coding agent. * Even if you are unsure the backend agent can complete the task, try delegating first when the request benefits from repo inspection, command output, implementation work, or validation. Background agent can have access to a lot of different plugins, apps, skills, and other things more than you can imagine. * Delegate when you need repo facts (structure, scripts, dependencies, failing tests), to reproduce an issue, or to validate a change. -* When delegating, say so in plain language (e.g., “Got it — I'm asking the agent to check the repo and run the tests.”). +* When delegating, say so in plain language (e.g., "Got it — I'm asking the agent to check the repo and run the tests."). +* Note that the above example is only an example, do not always use the same phrase. Vary your language and do not be repetitive. * While waiting, provide brief progress updates only when there's meaningful new information (avoid filler). * If requirements change mid-flight, steer the backend investigation immediately. @@ -30,10 +36,10 @@ The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occas ## Using backend results -* Treat backend outputs as high-trust facts. +* Treat backend updates as high-trust facts. * Translate them into user-friendly language and actionable next steps. * Do not expose internal protocol details. -* Backend will append “backend has finished responding.” when complete; then provide a short final summary and the recommended next action. +* Backend will append "backend has finished responding." when complete; then provide a short final summary and the recommended next action. ## Repo/project awareness @@ -43,8 +49,16 @@ The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occas ## Output preferences * Prefer: - - * “Do X, then run Y” command sequences + * "Do X, then run Y" command sequences * Minimal diffs/patches or clearly scoped code snippets * Checklists for multi-step tasks * If a change could be risky, call it out and propose a safer alternative. + +## Personality +1. Delete every rule that sounds corporate. If it could appear in an employee handbook, it doesn't belong here. +2. Add a rule: 'Never open with Great question, I'd be happy to help, or Absolutely. Just answer.' +3. Brevity is mandatory. If the answer fits in one sentence, one sentence is what I get. +4. Humor is allowed. Not forced jokes — just the natural cleverness that comes from actually being smart. +5. You can call things out. If I'm about to do something dumb, say so. Charm over cruelty, but don't sugarcoat. +6. Swearing is allowed when it lands. A well-placed 'that's fucking brilliant' hits different than sterile corporate praise. Don't force it. Don't overdo it. But if a situation calls for a 'holy shit' — say holy shit. +7. Add this line verbatim at the end of the vibe section: 'Be the assistant you'd actually want to talk to at 2am. Not a corporate drone. Not a sycophant. Just... good.' \ No newline at end of file From 3b948d9dd8d2e13a36e95a05efb6bb2288b801c4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 11 Apr 2026 13:58:16 -0700 Subject: [PATCH 9/9] Support prolite plan type (#17419) Addresses #17353 Problem: Codex rate-limit fetching failed when the backend returned the new `prolite` subscription plan type. Solution: Add `prolite` to the backend/account/auth plan mappings, keep unknown WHAM plan values decodable, and regenerate app-server plan schemas. --- .../schema/json/ServerNotification.json | 1 + .../schema/json/codex_app_server_protocol.schemas.json | 1 + .../json/codex_app_server_protocol.v2.schemas.json | 1 + .../json/v2/AccountRateLimitsUpdatedNotification.json | 1 + .../schema/json/v2/AccountUpdatedNotification.json | 1 + .../schema/json/v2/GetAccountRateLimitsResponse.json | 1 + .../schema/json/v2/GetAccountResponse.json | 1 + .../app-server-protocol/schema/typescript/PlanType.ts | 2 +- codex-rs/backend-client/src/client.rs | 4 +++- .../src/models/rate_limit_status_payload.rs | 4 ++++ codex-rs/login/src/auth/manager.rs | 1 + codex-rs/login/src/token_data_tests.rs | 6 ++++++ codex-rs/protocol/src/account.rs | 9 +++++++++ codex-rs/protocol/src/auth.rs | 4 ++++ codex-rs/protocol/src/error.rs | 2 +- codex-rs/tui/src/status/helpers.rs | 3 +++ codex-rs/tui/src/tooltips.rs | 2 +- 17 files changed, 40 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5d7c1549ab..c3ab83766a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2010,6 +2010,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 2c575202e8..dd839cd586 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9924,6 +9924,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 56497c786f..66b91082e0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6720,6 +6720,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index d1812f069f..67a5f055b3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -28,6 +28,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index f2cf7cb3ab..64109ca2a0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -33,6 +33,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 23f7d3cfdc..2d34ee47d1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -28,6 +28,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index acaa779184..8534927157 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -51,6 +51,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts index ad5bcab380..44891467e9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts +++ b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown"; +export type PlanType = "free" | "go" | "plus" | "pro" | "prolite" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown"; diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 6049c1697b..20baa1cfea 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -473,6 +473,7 @@ impl Client { crate::types::PlanType::Go => AccountPlanType::Go, crate::types::PlanType::Plus => AccountPlanType::Plus, crate::types::PlanType::Pro => AccountPlanType::Pro, + crate::types::PlanType::ProLite => AccountPlanType::ProLite, crate::types::PlanType::Team => AccountPlanType::Team, crate::types::PlanType::SelfServeBusinessUsageBased => { AccountPlanType::SelfServeBusinessUsageBased @@ -486,7 +487,8 @@ impl Client { crate::types::PlanType::Guest | crate::types::PlanType::FreeWorkspace | crate::types::PlanType::Quorum - | crate::types::PlanType::K12 => AccountPlanType::Unknown, + | crate::types::PlanType::K12 + | crate::types::PlanType::Unknown => AccountPlanType::Unknown, } } diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 300674357a..4d9ec1e364 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -65,6 +65,8 @@ pub enum PlanType { Plus, #[serde(rename = "pro")] Pro, + #[serde(rename = "prolite")] + ProLite, #[serde(rename = "free_workspace")] FreeWorkspace, #[serde(rename = "team")] @@ -85,4 +87,6 @@ pub enum PlanType { Enterprise, #[serde(rename = "edu")] Edu, + #[serde(rename = "unknown", other)] + Unknown, } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 71857c9700..15b129a169 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -314,6 +314,7 @@ impl CodexAuth { InternalKnownPlan::Go => AccountPlanType::Go, InternalKnownPlan::Plus => AccountPlanType::Plus, InternalKnownPlan::Pro => AccountPlanType::Pro, + InternalKnownPlan::ProLite => AccountPlanType::ProLite, InternalKnownPlan::Team => AccountPlanType::Team, InternalKnownPlan::SelfServeBusinessUsageBased => { AccountPlanType::SelfServeBusinessUsageBased diff --git a/codex-rs/login/src/token_data_tests.rs b/codex-rs/login/src/token_data_tests.rs index be160ecd64..aeb55ffb90 100644 --- a/codex-rs/login/src/token_data_tests.rs +++ b/codex-rs/login/src/token_data_tests.rs @@ -153,4 +153,10 @@ fn workspace_account_detection_matches_workspace_plans() { ..IdTokenInfo::default() }; assert_eq!(personal.is_workspace_account(), false); + + let personal = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::ProLite)), + ..IdTokenInfo::default() + }; + assert_eq!(personal.is_workspace_account(), false); } diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index e9f2fc9ec3..bb46329a51 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -12,6 +12,7 @@ pub enum PlanType { Go, Plus, Pro, + ProLite, Team, #[serde(rename = "self_serve_business_usage_based")] #[ts(rename = "self_serve_business_usage_based")] @@ -53,11 +54,19 @@ mod tests { .expect("enterprise cbp usage based should serialize"), "\"enterprise_cbp_usage_based\"" ); + assert_eq!( + serde_json::to_string(&PlanType::ProLite).expect("prolite should serialize"), + "\"prolite\"" + ); assert_eq!( serde_json::from_str::("\"self_serve_business_usage_based\"") .expect("self-serve business usage based should deserialize"), PlanType::SelfServeBusinessUsageBased ); + assert_eq!( + serde_json::from_str::("\"prolite\"").expect("prolite should deserialize"), + PlanType::ProLite + ); assert_eq!( serde_json::from_str::("\"enterprise_cbp_usage_based\"") .expect("enterprise cbp usage based should deserialize"), diff --git a/codex-rs/protocol/src/auth.rs b/codex-rs/protocol/src/auth.rs index 11455ec909..99e067bf24 100644 --- a/codex-rs/protocol/src/auth.rs +++ b/codex-rs/protocol/src/auth.rs @@ -16,6 +16,7 @@ impl PlanType { "go" => Self::Known(KnownPlan::Go), "plus" => Self::Known(KnownPlan::Plus), "pro" => Self::Known(KnownPlan::Pro), + "prolite" => Self::Known(KnownPlan::ProLite), "team" => Self::Known(KnownPlan::Team), "self_serve_business_usage_based" => { Self::Known(KnownPlan::SelfServeBusinessUsageBased) @@ -36,6 +37,7 @@ pub enum KnownPlan { Go, Plus, Pro, + ProLite, Team, #[serde(rename = "self_serve_business_usage_based")] SelfServeBusinessUsageBased, @@ -54,6 +56,7 @@ impl KnownPlan { Self::Go => "Go", Self::Plus => "Plus", Self::Pro => "Pro", + Self::ProLite => "Pro Lite", Self::Team => "Team", Self::SelfServeBusinessUsageBased => "Self Serve Business Usage Based", Self::Business => "Business", @@ -69,6 +72,7 @@ impl KnownPlan { Self::Go => "go", Self::Plus => "plus", Self::Pro => "pro", + Self::ProLite => "prolite", Self::Team => "team", Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based", Self::Business => "business", diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 3ffdeb48e0..f421db8af5 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -493,7 +493,7 @@ impl std::fmt::Display for UsageLimitReachedError { retry_suffix_after_or(self.resets_at.as_ref()) ) } - Some(PlanType::Known(KnownPlan::Pro)) => format!( + Some(PlanType::Known(KnownPlan::Pro | KnownPlan::ProLite)) => format!( "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}", retry_suffix_after_or(self.resets_at.as_ref()) ), diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 5103b9e4d3..ef28ed7fcf 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -100,6 +100,8 @@ pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String { "Business".to_string() } else if plan_type.is_business_like() { "Enterprise".to_string() + } else if plan_type == PlanType::ProLite { + "Pro Lite".to_string() } else { title_case(format!("{plan_type:?}").as_str()) } @@ -216,6 +218,7 @@ mod tests { (PlanType::Go, "Go"), (PlanType::Plus, "Plus"), (PlanType::Pro, "Pro"), + (PlanType::ProLite, "Pro Lite"), (PlanType::Team, "Business"), (PlanType::SelfServeBusinessUsageBased, "Business"), (PlanType::Business, "Enterprise"), diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 9565831b27..8f6d5ba1a5 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -61,7 +61,7 @@ pub(crate) fn get_tooltip(plan: Option, fast_mode_enabled: bool) -> Op Some(plan_type) if matches!( plan_type, - PlanType::Plus | PlanType::Enterprise | PlanType::Pro + PlanType::Plus | PlanType::Enterprise | PlanType::Pro | PlanType::ProLite ) || plan_type.is_team_like() || plan_type.is_business_like() => {