feat(code-rs): add forked workspace and dual build support

This commit is contained in:
James Peter
2025-10-05 04:27:49 +10:00
parent d7912f8665
commit 220a6b3b9c
1066 changed files with 233191 additions and 45 deletions
+104 -34
View File
@@ -14,6 +14,7 @@ Environment flags:
DETERMINISTIC=1 Add -C debuginfo=0; promotes to release-prod unless DETERMINISTIC_FORCE_RELEASE=0
DETERMINISTIC_FORCE_RELEASE=0|1 Keep dev-fast (0) or switch to release-prod (1, default)
DETERMINISTIC_NO_UUID=1 macOS only: strip LC_UUID on final executables
--workspace codex|code|both Select workspace to build (default: codex)
Examples:
./build-fast.sh
@@ -43,7 +44,7 @@ resolve_bin_path() {
if [ -n "${CARGO_TARGET_DIR:-}" ]; then
target_root="${CARGO_TARGET_DIR}"
else
target_root="${REPO_ROOT}/codex-rs/target"
target_root="${REPO_ROOT}/${WORKSPACE_DIR}/target"
fi
if [[ "${target_root}" != /* ]]; then
@@ -63,7 +64,7 @@ resolve_bin_path() {
BIN_LINK_PATH="./target/${BIN_SUBPATH}"
if [ -n "${REPO_TARGET_ABS:-}" ] && [ "${TARGET_DIR_ABS}" = "${REPO_TARGET_ABS}" ]; then
BIN_DISPLAY_PATH="./codex-rs/target/${BIN_SUBPATH}"
BIN_DISPLAY_PATH="./${WORKSPACE_DIR}/target/${BIN_SUBPATH}"
else
BIN_DISPLAY_PATH="${BIN_PATH}"
fi
@@ -75,10 +76,24 @@ esac
RUN_AFTER_BUILD=0
ARG_PROFILE=""
WORKSPACE_CHOICE="${WORKSPACE:-}"
PASSTHROUGH_ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
run)
RUN_AFTER_BUILD=1
PASSTHROUGH_ARGS+=("$1")
;;
--workspace)
shift || { echo "Error: --workspace requires a value." >&2; usage; exit 1; }
WORKSPACE_CHOICE="$1"
;;
--workspace=*)
WORKSPACE_CHOICE="${1#*=}"
;;
-h|--help)
usage
exit 0
;;
*)
if [ -n "$ARG_PROFILE" ]; then
@@ -87,11 +102,27 @@ while [ $# -gt 0 ]; do
exit 1
fi
ARG_PROFILE="$1"
PASSTHROUGH_ARGS+=("$1")
;;
esac
shift
done
if [ -z "$WORKSPACE_CHOICE" ]; then
WORKSPACE_CHOICE="codex"
fi
if [ "$WORKSPACE_CHOICE" = "both" ]; then
if [ "$RUN_AFTER_BUILD" -eq 1 ]; then
echo "Error: --workspace both cannot be combined with 'run'." >&2
exit 1
fi
for ws in codex code; do
WORKSPACE="$ws" "$0" "${PASSTHROUGH_ARGS[@]}" --workspace "$ws"
done
exit 0
fi
if [ "$ARG_PROFILE" = "pref" ]; then
ARG_PROFILE="perf"
fi
@@ -100,8 +131,38 @@ fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
CALLER_CWD="$(pwd)"
# Change to the Rust project root directory (codex-rs) regardless of caller CWD
cd "${SCRIPT_DIR}/codex-rs"
case "$WORKSPACE_CHOICE" in
codex|codex-rs)
WORKSPACE_DIR="codex-rs"
CRATE_PREFIX="codex"
;;
code|code-rs)
WORKSPACE_DIR="code-rs"
CRATE_PREFIX="code"
;;
*)
echo "Error: Unknown workspace '${WORKSPACE_CHOICE}'. Use codex, code, or both." >&2
exit 1
;;
esac
WORKSPACE_PATH="${SCRIPT_DIR}/${WORKSPACE_DIR}"
if [ ! -d "$WORKSPACE_PATH" ]; then
echo "Error: Workspace directory '${WORKSPACE_PATH}' not found." >&2
exit 1
fi
# Change to the selected Rust workspace root regardless of caller CWD
cd "${WORKSPACE_PATH}"
CLI_PACKAGE="$(sed -n 's/^name\s*=\s*"\(.*\)"/\1/p' cli/Cargo.toml | head -n1)"
TUI_PACKAGE="$(sed -n 's/^name\s*=\s*"\(.*\)"/\1/p' tui/Cargo.toml | head -n1)"
EXEC_PACKAGE="$(sed -n 's/^name\s*=\s*"\(.*\)"/\1/p' exec/Cargo.toml | head -n1)"
CRATE_PREFIX="${CLI_PACKAGE%%-*}"
EXEC_BIN="$(awk 'BEGIN{inbin=0} /^\[\[bin\]\]/{inbin=1; next} inbin && /^name[[:space:]]*=/{gsub(/.*"/,"",$0); gsub(/"/,"",$0); print; exit}' exec/Cargo.toml)"
if [ -z "${EXEC_BIN}" ]; then
EXEC_BIN="${EXEC_PACKAGE}"
fi
# Compute repository root (the directory containing this script)
# Note: We intentionally set REPO_ROOT to SCRIPT_DIR so any defaults (like CARGO_HOME)
@@ -253,7 +314,7 @@ if [ -z "${RUSTUP_HOME:-}" ]; then
export RUSTUP_HOME="${CARGO_HOME%/}/rustup"
fi
if [ -z "${CARGO_TARGET_DIR:-}" ]; then
export CARGO_TARGET_DIR="${SCRIPT_DIR}/codex-rs/target"
export CARGO_TARGET_DIR="${WORKSPACE_PATH}/target"
fi
mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" 2>/dev/null || true
# Ensure repo-local target directory exists for compatibility symlinks
@@ -373,11 +434,9 @@ fi
# Build for native target (no --target flag) for maximum speed
# This reuses the host stdlib and normal cache
# Determine exec binary name based on workspace (support forks)
EXEC_BIN="codex-exec"
# Detect legacy bin name used in some forks
if grep -q '^name\s*=\s*"code-exec"' ./exec/Cargo.toml 2>/dev/null; then
EXEC_BIN="code-exec"
# Determine exec binary name based on workspace metadata
if [ -z "${EXEC_BIN}" ]; then
EXEC_BIN="${CRATE_PREFIX}-exec"
fi
# Build with or without --locked based on lockfile validity
@@ -412,13 +471,36 @@ if [ $? -eq 0 ]; then
# Keep old symlink locations working for compatibility
# Create symlink in target/release for npm wrapper expectations
release_link_target="../${BIN_SUBDIR}/${BIN_FILENAME}"
cli_link_target="../../codex-rs/target/${BIN_SUBDIR}/${BIN_FILENAME}"
dev_fast_link_target="../${BIN_SUBDIR}/${BIN_FILENAME}"
create_cli_symlinks() {
local cli_dir="$1"
local default_target="$2"
mkdir -p "$cli_dir"
local link_target="$default_target"
if [ -n "${CLI_LINK_ABSOLUTE}" ]; then
link_target="${CLI_LINK_ABSOLUTE}"
fi
for LINK in "code-${TRIPLE}" "coder-${TRIPLE}"; do
local dest="${cli_dir}/${LINK}"
[ -e "$dest" ] && rm -f "$dest"
ln -sf "${link_target}" "$dest"
done
for LINK in code-aarch64-apple-darwin coder-aarch64-apple-darwin; do
local dest="${cli_dir}/${LINK}"
[ -e "$dest" ] && rm -f "$dest"
ln -sf "${link_target}" "$dest"
done
}
CLI_TARGET_CODE="../../target/${BIN_SUBDIR}/${BIN_FILENAME}"
CLI_TARGET_CODEX="../../${WORKSPACE_DIR}/target/${BIN_SUBDIR}/${BIN_FILENAME}"
CLI_LINK_ABSOLUTE=""
if [ "${TARGET_DIR_ABS}" != "${REPO_TARGET_ABS}" ]; then
release_link_target="${BIN_PATH}"
cli_link_target="${BIN_PATH}"
dev_fast_link_target="${BIN_PATH}"
CLI_LINK_ABSOLUTE="${BIN_PATH}"
# Maintain repo-local path for downstream tooling when target dir is external
if [ -n "${BIN_LINK_PATH:-}" ]; then
@@ -436,21 +518,13 @@ if [ $? -eq 0 ]; then
fi
ln -sf "${release_link_target}" "./target/release/code"
# Update the symlinks in codex-cli/bin
CLI_BIN_DIR="../codex-cli/bin"
mkdir -p "$CLI_BIN_DIR"
# Dynamic arch-targeted names
for LINK in "code-${TRIPLE}" "coder-${TRIPLE}"; do
DEST="${CLI_BIN_DIR}/${LINK}"
[ -e "$DEST" ] && rm -f "$DEST"
ln -sf "${cli_link_target}" "$DEST"
done
# Back-compat fixed names (Apple Silicon triple)
for LINK in code-aarch64-apple-darwin coder-aarch64-apple-darwin; do
DEST="${CLI_BIN_DIR}/${LINK}"
[ -e "$DEST" ] && rm -f "$DEST"
ln -sf "${cli_link_target}" "$DEST"
done
# Update the symlinks in CLI wrapper directories
if [ -d "../codex-cli/bin" ]; then
create_cli_symlinks "../codex-cli/bin" "${CLI_TARGET_CODEX}"
fi
if [ -d "./code-cli/bin" ]; then
create_cli_symlinks "./code-cli/bin" "${CLI_TARGET_CODE}"
fi
# Ensure repo-local 'code-dev' path stays mapped to latest build output
# so the user's alias `code-dev` (if pointing at target/dev-fast/code) keeps working
@@ -468,13 +542,9 @@ if [ $? -eq 0 ]; then
# dependencies/proc-macro dylibs are not affected.
if [ "${DETERMINISTIC_NO_UUID:-}" = "1" ] && [ "$(uname -s)" = "Darwin" ]; then
echo "Deterministic post-link: removing LC_UUID from executables"
${USE_CARGO} rustc ${USE_LOCKED} --profile "${PROFILE}" -p codex-cli --bin code -- -C link-arg=-Wl,-no_uuid || true
${USE_CARGO} rustc ${USE_LOCKED} --profile "${PROFILE}" -p codex-tui --bin code-tui -- -C link-arg=-Wl,-no_uuid || true
if [ "$EXEC_BIN" = "codex-exec" ]; then
${USE_CARGO} rustc ${USE_LOCKED} --profile "${PROFILE}" -p codex-exec --bin codex-exec -- -C link-arg=-Wl,-no_uuid || true
else
${USE_CARGO} rustc ${USE_LOCKED} --profile "${PROFILE}" -p codex-exec --bin code-exec -- -C link-arg=-Wl,-no_uuid || true
fi
${USE_CARGO} rustc ${USE_LOCKED} --profile "${PROFILE}" -p "${CLI_PACKAGE}" --bin code -- -C link-arg=-Wl,-no_uuid || true
${USE_CARGO} rustc ${USE_LOCKED} --profile "${PROFILE}" -p "${TUI_PACKAGE}" --bin code-tui -- -C link-arg=-Wl,-no_uuid || true
${USE_CARGO} rustc ${USE_LOCKED} --profile "${PROFILE}" -p "${EXEC_PACKAGE}" --bin "${EXEC_BIN}" -- -C link-arg=-Wl,-no_uuid || true
fi
# Compute absolute path and SHA256 for clarity (after any post-linking)
+7
View File
@@ -0,0 +1,7 @@
/target/
# Recommended value of CARGO_TARGET_DIR when using Docker as explained in .devcontainer/README.md.
/target-amd64/
# Value of CARGO_TARGET_DIR when using .devcontainer/devcontainer.json.
/target-arm64/
+8145
View File
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
[workspace]
members = [
"backend-client",
"ansi-escape",
"app-server",
"app-server-protocol",
"apply-patch",
"arg0",
"code-backend-openapi-models",
"cloud-tasks",
"cloud-tasks-client",
"cli",
"common",
"core",
"exec",
"execpolicy",
"file-search",
"git-tooling",
"linux-sandbox",
"login",
"mcp-client",
"mcp-server",
"mcp-types",
"ollama",
"process-hardening",
"protocol",
"protocol-ts",
"rmcp-client",
"responses-api-proxy",
"otel",
"tui",
"git-apply",
"utils/json-to-toml",
"utils/readiness",
]
resolver = "2"
[workspace.package]
version = "0.0.0"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
# crates created with `cargo new -w ...` automatically inherit the 2024
# edition.
edition = "2024"
[workspace.dependencies]
# Internal
app_test_support = { path = "app-server/tests/common" }
code-ansi-escape = { path = "ansi-escape" }
code-app-server = { path = "app-server" }
code-app-server-protocol = { path = "app-server-protocol" }
code-apply-patch = { path = "apply-patch" }
code-arg0 = { path = "arg0" }
code-chatgpt = { path = "chatgpt" }
code-common = { path = "common" }
code-core = { path = "core" }
code-cloud-tasks = { path = "cloud-tasks" }
code-cloud-tasks-client = { path = "cloud-tasks-client" }
code-exec = { path = "exec" }
code-file-search = { path = "file-search" }
code-git-tooling = { path = "git-tooling" }
code-linux-sandbox = { path = "linux-sandbox" }
code-login = { path = "login" }
code-mcp-client = { path = "mcp-client" }
code-mcp-server = { path = "mcp-server" }
code-ollama = { path = "ollama" }
code-process-hardening = { path = "process-hardening" }
code-protocol = { path = "protocol" }
code-protocol-ts = { path = "protocol-ts" }
code-responses-api-proxy = { path = "responses-api-proxy" }
code-rmcp-client = { path = "rmcp-client" }
code-otel = { path = "otel" }
code-tui = { path = "tui" }
code-utils-json-to-toml = { path = "utils/json-to-toml" }
code-utils-readiness = { path = "utils/readiness" }
core_test_support = { path = "core/tests/common" }
mcp-types = { path = "mcp-types" }
mcp_test_support = { path = "mcp-server/tests/common" }
# External
allocative = "0.3.3"
ansi-to-tui = "7.0.0"
anyhow = "1"
arboard = "3"
askama = "0.12"
assert_cmd = "2"
async-channel = "2.3.1"
async-stream = "0.3.6"
async-trait = "0.1.89"
base64 = "0.22.1"
bytes = "1.10.1"
chrono = "0.4.42"
clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
crossterm = "0.28.1"
ctor = "0.5.0"
derive_more = "2"
diffy = "0.4.2"
dirs = "6"
dotenvy = "0.15.7"
dunce = "1.0.5"
env-flags = "0.1.1"
env_logger = "0.11.5"
escargot = "0.5"
eventsource-stream = "0.2.3"
futures = "0.3"
icu_decimal = "2.0.0"
icu_locale_core = "2.0.0"
ignore = "0.4.23"
image = { version = "^0.25.8", default-features = false }
indexmap = "2.6.0"
insta = "1.43.2"
itertools = "0.14.0"
landlock = "0.4.1"
lazy_static = "1"
libc = "0.2.175"
log = "0.4"
maplit = "1.0.2"
mime_guess = "2.0.5"
multimap = "0.10.0"
nucleo-matcher = "0.3.1"
openssl-sys = "*"
opentelemetry = "0.30.0"
opentelemetry-appender-tracing = "0.30.0"
opentelemetry-otlp = "0.30.0"
opentelemetry-semantic-conventions = "0.30.0"
opentelemetry_sdk = "0.30.0"
os_info = "3.12.0"
owo-colors = "4.2.0"
path-absolutize = "3.1.1"
path-clean = "1.0.1"
pathdiff = "0.2"
portable-pty = "0.9.0"
predicates = "3"
pretty_assertions = "1.4.1"
pulldown-cmark = "0.10"
rand = "0.9"
ratatui = "0.29.0"
regex-lite = "0.1.7"
reqwest = "0.12"
schemars = "0.8.22"
seccompiler = "0.5.0"
serde = "1"
serde_json = "1"
serde_with = "3.14"
sha1 = "0.10.6"
sha2 = "0.10"
shlex = "1.3.0"
similar = "2.7.0"
starlark = "0.13.0"
strum = "0.27.2"
strum_macros = "0.27.2"
supports-color = "3.0.2"
sys-locale = "0.3.2"
tempfile = "3.23.0"
textwrap = "0.16.2"
thiserror = "2.0.16"
time = "0.3"
tiny_http = "0.12"
tokio = "1"
tokio-stream = "0.1.17"
tokio-test = "0.4"
tokio-util = "0.7.16"
toml = "0.9.5"
toml_edit = "0.23.4"
tonic = "0.13.1"
tracing = "0.1.41"
tracing-appender = "0.2.3"
tracing-subscriber = "0.3.20"
tracing-test = "0.2.5"
tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
ts-rs = "11"
unicode-segmentation = "1.12.0"
unicode-width = "0.2"
url = "2"
urlencoding = "2.1"
uuid = "1"
vt100 = "0.16.2"
walkdir = "2.5.0"
webbrowser = "1.0"
which = "6"
wildmatch = "2.5.0"
wiremock = "0.6"
zeroize = "1.8.1"
[workspace.lints]
rust = {}
[workspace.lints.clippy]
expect_used = "deny"
identity_op = "deny"
manual_clamp = "deny"
manual_filter = "deny"
manual_find = "deny"
manual_flatten = "deny"
manual_map = "deny"
manual_memcpy = "deny"
manual_non_exhaustive = "deny"
manual_ok_or = "deny"
manual_range_contains = "deny"
manual_retain = "deny"
manual_strip = "deny"
manual_try_fold = "deny"
manual_unwrap_or = "deny"
needless_borrow = "deny"
needless_borrowed_reference = "deny"
needless_collect = "deny"
needless_late_init = "deny"
needless_option_as_deref = "deny"
needless_question_mark = "deny"
needless_update = "deny"
redundant_clone = "deny"
redundant_closure = "deny"
redundant_closure_for_method_calls = "deny"
redundant_static_lifetimes = "deny"
trivially_copy_pass_by_ref = "deny"
uninlined_format_args = "deny"
unnecessary_filter_map = "deny"
unnecessary_lazy_evaluations = "deny"
unnecessary_sort_by = "deny"
unnecessary_to_owned = "deny"
unwrap_used = "deny"
# cargo-shear cannot see the platform-specific openssl-sys usage, so we
# silence the false positive here instead of deleting a real dependency.
[workspace.metadata.cargo-shear]
ignored = ["openssl-sys", "code-utils-readiness"]
[profile.release]
lto = "fat"
# Because we bundle some of these executables with the TypeScript CLI, we
# remove everything to make the binary as small as possible.
strip = "symbols"
# See https://github.com/openai/codex/issues/1411 for details.
codegen-units = 1
[patch.crates-io]
# ratatui = { path = "../../ratatui" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
# Custom build profiles used by build-fast.sh
[profile.dev-fast]
inherits = "dev"
opt-level = 1
debug = 1
incremental = true
codegen-units = 256
lto = "off"
[profile.perf]
inherits = "release"
incremental = true
codegen-units = 256
lto = "off"
debug = 2
strip = "none"
split-debuginfo = "packed"
[profile.release-prod]
inherits = "release"
lto = "fat"
strip = "symbols"
codegen-units = 1
+129
View File
@@ -0,0 +1,129 @@
# Codex CLI (Rust Implementation)
We provide Codex CLI as a standalone, native executable to ensure a zero-dependency install.
## Installing Codex
Today, the easiest way to install Codex is via `npm`:
```shell
npm i -g @openai/codex
codex
```
You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases).
## What's new in the Rust CLI
The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported.
### Config
Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details.
### Model Context Protocol Support
Codex CLI functions as an MCP client that can connect to MCP servers on startup. See the [`mcp_servers`](../docs/config.md#mcp_servers) section in the configuration documentation for details.
It is still experimental, but you can also launch Codex as an MCP _server_ by running `codex mcp-server`. Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out:
```shell
npx @modelcontextprotocol/inspector codex mcp-server
```
Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly.
### Notifications
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS.
### `codex exec` to run Codex programmatically/non-interactively
To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on.
### Use `@` for file search
Typing `@` triggers a fuzzy-filename search over the workspace root. Use up/down to select among the results and Tab or Enter to replace the `@` with the selected path. You can use Esc to cancel the search.
### Esc–Esc to edit a previous message
When the chat composer is empty, press Esc to prime “backtrack” mode. Press Esc again to open a transcript preview highlighting the last user message; press Esc repeatedly to step to older user messages. Press Enter to confirm and Codex will fork the conversation from that point, trim the visible transcript accordingly, and pre‑fill the composer with the selected user message so you can edit and resubmit it.
In the transcript preview, the footer shows an `Esc edit prev` hint while editing is active.
### `--cd`/`-C` flag
Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session.
### Shell completions
Generate shell completion scripts via:
```shell
code completion bash
code completion zsh
code completion fish
```
### Experimenting with the Codex Sandbox
To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI:
```
# macOS
codex debug seatbelt [--full-auto] [COMMAND]...
# Linux
codex debug landlock [--full-auto] [COMMAND]...
```
### Selecting a sandbox policy via `--sandbox`
The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option:
```shell
# Run Codex with the default, read-only sandbox
codex --sandbox read-only
# Allow the agent to write within the current workspace while still blocking network access
codex --sandbox workspace-write
# Danger! Disable sandboxing entirely (only do this if you are already running in a container or other isolated env)
codex --sandbox danger-full-access
```
The same setting can be persisted in `~/.code/config.toml` via the top-level `sandbox_mode = "MODE"` key (Code will also read legacy `~/.codex/config.toml`), e.g. `sandbox_mode = "workspace-write"`.
If you want to prevent the agent from updating Git metadata (e.g., local safety), you can opt‑out with a workspace‑write tweak:
```toml
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
allow_git_writes = false # default is true; set false to protect .git
```
### Debugging Virtual Cursor
Use these console helpers to diagnose motion/cancellation behavior when testing in a real browser:
- Disable clickPulse transforms and force long CSS duration:
`window.__vc && (window.__vc.clickPulse = () => (console.debug('[VC] clickPulse disabled'), 0), window.__vc.setMotion({ engine: 'css', cssDurationMs: 10000 }))`
- Wrap `moveTo` to log duplicates with sequence and inter-call delta:
`(() => { const vc = window.__vc; if (!vc || vc.__wrapped) return; const orig = vc.moveTo; let seq=0, last=0; vc.moveTo = function(x,y,o){ const now=Date.now(); console.debug('[VC] moveTo call',{seq:++seq,x,y,o,sincePrevMs:last?now-last:null}); last=now; return orig.call(this,x,y,o); }; vc.__wrapped = true; console.debug('[VC] moveTo wrapper installed'); })();`
- Trigger a test move (adjust coordinates as needed):
`window.__vc && window.__vc.moveTo(200, 200)`
## Code Organization
This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates:
- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this to be a library crate that is generally useful for building other Rust/native applications that use Codex.
- [`exec/`](./exec) "headless" CLI for use in automation.
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands.
+16
View File
@@ -0,0 +1,16 @@
[package]
edition = "2024"
name = "code-ansi-escape"
version = { workspace = true }
[lib]
name = "code_ansi_escape"
path = "src/lib.rs"
[dependencies]
ansi-to-tui = { workspace = true }
ratatui = { workspace = true, features = [
"unstable-rendered-line-info",
"unstable-widget-ref",
] }
tracing = { workspace = true, features = ["log"] }
+15
View File
@@ -0,0 +1,15 @@
# oai-codex-ansi-escape
Small helper functions that wrap functionality from
<https://crates.io/crates/ansi-to-tui>:
```rust
pub fn ansi_escape_line(s: &str) -> Line<'static>
pub fn ansi_escape<'a>(s: &'a str) -> Text<'a>
```
Advantages:
- `ansi_to_tui::IntoText` is not in scope for the entire TUI crate
- we `panic!()` and log if `IntoText` returns an `Err` and log it so that
the caller does not have to deal with it
+39
View File
@@ -0,0 +1,39 @@
use ansi_to_tui::Error;
use ansi_to_tui::IntoText;
use ratatui::text::Line;
use ratatui::text::Text;
/// This function should be used when the contents of `s` are expected to match
/// a single line. If multiple lines are found, a warning is logged and only the
/// first line is returned.
pub fn ansi_escape_line(s: &str) -> Line<'static> {
let text = ansi_escape(s);
match text.lines.as_slice() {
[] => "".into(),
[only] => only.clone(),
[first, rest @ ..] => {
tracing::warn!("ansi_escape_line: expected a single line, got {first:?} and {rest:?}");
first.clone()
}
}
}
pub fn ansi_escape(s: &str) -> Text<'static> {
// to_text() claims to be faster, but introduces complex lifetime issues
// such that it's not worth it.
match s.into_text() {
Ok(text) => text,
Err(err) => match err {
Error::NomError(message) => {
tracing::error!(
"ansi_to_tui NomError docs claim should never happen when parsing `{s}`: {message}"
);
panic!();
}
Error::Utf8Error(utf8error) => {
tracing::error!("Utf8Error: {utf8error}");
panic!();
}
},
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "code-app-server-protocol"
version = { workspace = true }
edition = "2024"
[lints]
workspace = true
[dependencies]
code-protocol = { workspace = true }
mcp-types = { workspace = true }
+71
View File
@@ -0,0 +1,71 @@
//! Compatibility shim for the legacy `code_app_server_protocol` crate.
//! This crate re-exports the MCP protocol surface that downstream crates
//! still depend on while the fork finishes migrating to the new structure.
pub use code_protocol::mcp_protocol::{
self,
AddConversationListenerParams,
AddConversationSubscriptionResponse,
ApplyPatchApprovalParams,
ApplyPatchApprovalResponse,
ArchiveConversationParams,
ArchiveConversationResponse,
AuthMode,
CancelLoginChatGptParams,
CancelLoginChatGptResponse,
ClientInfo,
ClientNotification,
ClientRequest,
ConversationId,
ExecCommandApprovalParams,
ExecCommandApprovalResponse,
GetAuthStatusParams,
GetAuthStatusResponse,
GetUserAgentResponse,
GetUserSavedConfigResponse,
GitDiffToRemoteParams,
GitDiffToRemoteResponse,
GitSha,
InitializeParams,
InputItem,
InterruptConversationParams,
InterruptConversationResponse,
ListConversationsParams,
ListConversationsResponse,
LoginApiKeyParams,
LoginChatGptCompleteNotification,
LoginChatGptResponse,
LogoutChatGptParams,
LogoutChatGptResponse,
NewConversationParams,
NewConversationResponse,
Profile,
RemoveConversationListenerParams,
RemoveConversationSubscriptionResponse,
ResumeConversationParams,
ResumeConversationResponse,
SandboxSettings,
SendUserMessageParams,
SendUserMessageResponse,
SendUserTurnParams,
SendUserTurnResponse,
ServerNotification,
ServerRequest,
SessionConfiguredNotification,
SetDefaultModelParams,
SetDefaultModelResponse,
Tools,
UserInfoResponse,
UserSavedConfig,
APPLY_PATCH_APPROVAL_METHOD,
EXEC_COMMAND_APPROVAL_METHOD,
};
pub use mcp_types::{
JSONRPCError,
JSONRPCErrorError,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
RequestId,
};
+51
View File
@@ -0,0 +1,51 @@
[package]
edition = "2024"
name = "code-app-server"
version = { workspace = true }
[[bin]]
name = "code-app-server"
path = "src/main.rs"
[lib]
name = "code_app_server"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
code-arg0 = { workspace = true }
code-common = { workspace = true, features = ["cli"] }
code-core = { workspace = true }
code-file-search = { workspace = true }
code-login = { workspace = true }
code-protocol = { workspace = true }
code-utils-json-to-toml = { workspace = true }
# We should only be using mcp-types for JSON-RPC types: it would be nice to
# split this out into a separate crate at some point.
mcp-types = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
"signal",
] }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies]
app_test_support = { workspace = true }
assert_cmd = { workspace = true }
base64 = { workspace = true }
core_test_support = { workspace = true }
os_info = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
toml = { workspace = true }
wiremock = { workspace = true }
@@ -0,0 +1,768 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use code_core::AuthManager;
use code_core::CodexConversation;
use code_core::ConversationManager;
use code_core::NewConversation;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use code_core::git_info::git_diff_to_remote;
use code_core::protocol::ApplyPatchApprovalRequestEvent;
use code_core::protocol::Event;
use code_core::protocol::EventMsg;
use code_core::protocol::ExecApprovalRequestEvent;
use code_protocol::mcp_protocol::FuzzyFileSearchParams;
use code_protocol::mcp_protocol::FuzzyFileSearchResponse;
use code_protocol::protocol::ReviewDecision;
use mcp_types::JSONRPCErrorError;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tracing::error;
use uuid::Uuid;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use code_utils_json_to_toml::json_to_toml;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use crate::fuzzy_file_search::run_fuzzy_file_search;
use code_protocol::protocol::TurnAbortReason;
use code_core::protocol::InputItem as CoreInputItem;
use code_core::protocol::Op;
use code_core::protocol as core_protocol;
use code_protocol::mcp_protocol::APPLY_PATCH_APPROVAL_METHOD;
use code_protocol::mcp_protocol::AddConversationListenerParams;
use code_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use code_protocol::mcp_protocol::ApplyPatchApprovalParams;
use code_protocol::mcp_protocol::ApplyPatchApprovalResponse;
use code_protocol::mcp_protocol::ClientRequest;
use code_protocol::mcp_protocol::ConversationId;
use code_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD;
use code_protocol::mcp_protocol::ExecCommandApprovalParams;
use code_protocol::mcp_protocol::ExecCommandApprovalResponse;
use code_protocol::mcp_protocol::InputItem as WireInputItem;
use code_protocol::mcp_protocol::InterruptConversationParams;
use code_protocol::mcp_protocol::InterruptConversationResponse;
// Unused login-related and diff param imports removed
use code_protocol::mcp_protocol::GitDiffToRemoteResponse;
use code_protocol::mcp_protocol::NewConversationParams;
use code_protocol::mcp_protocol::NewConversationResponse;
use code_protocol::mcp_protocol::RemoveConversationListenerParams;
use code_protocol::mcp_protocol::RemoveConversationSubscriptionResponse;
use code_protocol::mcp_protocol::SendUserMessageParams;
use code_protocol::mcp_protocol::SendUserMessageResponse;
use code_protocol::mcp_protocol::SendUserTurnParams;
use code_protocol::mcp_protocol::SendUserTurnResponse;
// Removed deprecated ChatGPT login support scaffolding
/// Handles JSON-RPC messages for Codex conversations.
pub struct CodexMessageProcessor {
_auth_manager: Arc<AuthManager>,
conversation_manager: Arc<ConversationManager>,
outgoing: Arc<OutgoingMessageSender>,
code_linux_sandbox_exe: Option<PathBuf>,
_config: Arc<Config>,
conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
pending_interrupts: Arc<Mutex<HashMap<Uuid, Vec<RequestId>>>>,
#[allow(dead_code)]
pending_fuzzy_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
}
impl CodexMessageProcessor {
pub fn new(
auth_manager: Arc<AuthManager>,
conversation_manager: Arc<ConversationManager>,
outgoing: Arc<OutgoingMessageSender>,
code_linux_sandbox_exe: Option<PathBuf>,
config: Arc<Config>,
) -> Self {
Self {
_auth_manager: auth_manager,
conversation_manager,
outgoing,
code_linux_sandbox_exe,
_config: config,
conversation_listeners: HashMap::new(),
pending_interrupts: Arc::new(Mutex::new(HashMap::new())),
pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn process_request(&mut self, request: ClientRequest) {
match request {
ClientRequest::Initialize { .. } => {
panic!("Initialize should be handled in MessageProcessor");
}
ClientRequest::NewConversation { request_id, params } => {
// Do not tokio::spawn() to process new_conversation()
// asynchronously because we need to ensure the conversation is
// created before processing any subsequent messages.
self.process_new_conversation(request_id, params).await;
}
ClientRequest::SendUserMessage { request_id, params } => {
self.send_user_message(request_id, params).await;
}
ClientRequest::InterruptConversation { request_id, params } => {
self.interrupt_conversation(request_id, params).await;
}
ClientRequest::AddConversationListener { request_id, params } => {
self.add_conversation_listener(request_id, params).await;
}
ClientRequest::RemoveConversationListener { request_id, params } => {
self.remove_conversation_listener(request_id, params).await;
}
ClientRequest::SendUserTurn { request_id, params } => {
self.send_user_turn_compat(request_id, params).await;
}
ClientRequest::LoginChatGpt { request_id, .. } => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "login is not supported by this server".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
ClientRequest::CancelLoginChatGpt { request_id, .. } => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "cancel login is not supported by this server".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
ClientRequest::LogoutChatGpt { request_id, .. } => {
// Not supported by this server implementation
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "logout is not supported by this server".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
ClientRequest::GetAuthStatus { request_id, .. } => {
// Not supported by this server implementation
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "auth status is not supported by this server".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
ClientRequest::GitDiffToRemote { request_id, params } => {
self.git_diff_to_origin(request_id, params.cwd).await;
}
_ => { /* ignore unsupported methods */ }
}
}
// Upstream added utility endpoints (user info, set default model, one-off exec).
// Our fork does not expose them via this server; omit to preserve behavior.
async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) {
let config = match derive_config_from_params(params, self.code_linux_sandbox_exe.clone()) {
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self.conversation_manager.new_conversation(config).await {
Ok(conversation_id) => {
let NewConversation {
conversation_id,
session_configured,
..
} = conversation_id;
let response = NewConversationResponse {
conversation_id,
model: session_configured.model,
reasoning_effort: None,
// We do not expose the underlying rollout file path in this fork; provide the sessions root.
rollout_path: self._config.code_home.join("sessions"),
};
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("error creating conversation: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
}
async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) {
let SendUserMessageParams {
conversation_id,
items,
} = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
let mapped_items: Vec<CoreInputItem> = items
.into_iter()
.map(|item| match item {
WireInputItem::Text { text } => CoreInputItem::Text { text },
WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
})
.collect();
// Submit user input to the conversation.
let _ = conversation
.submit(Op::UserInput {
items: mapped_items,
})
.await;
// Acknowledge with an empty result.
self.outgoing
.send_response(request_id, SendUserMessageResponse {})
.await;
}
#[allow(dead_code)]
async fn send_user_turn(&self, request_id: RequestId, params: SendUserTurnParams) {
let SendUserTurnParams {
conversation_id,
items,
cwd: _,
approval_policy: _,
sandbox_policy: _,
model: _,
effort: _,
summary: _,
} = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
let mapped_items: Vec<CoreInputItem> = items
.into_iter()
.map(|item| match item {
WireInputItem::Text { text } => CoreInputItem::Text { text },
WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
})
.collect();
// Core protocol compatibility: older cores do not support per-turn overrides.
// Submit only the user input items.
let _ = conversation
.submit(Op::UserInput { items: mapped_items })
.await;
self.outgoing
.send_response(request_id, SendUserTurnResponse {})
.await;
}
async fn interrupt_conversation(
&mut self,
request_id: RequestId,
params: InterruptConversationParams,
) {
let InterruptConversationParams { conversation_id } = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
// Submit the interrupt and respond immediately (core does not emit a dedicated event).
let _ = conversation.submit(Op::Interrupt).await;
let response = InterruptConversationResponse { abort_reason: TurnAbortReason::Interrupted };
self.outgoing.send_response(request_id, response).await;
}
async fn add_conversation_listener(
&mut self,
request_id: RequestId,
params: AddConversationListenerParams,
) {
let AddConversationListenerParams { conversation_id } = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
let subscription_id = Uuid::new_v4();
let (cancel_tx, mut cancel_rx) = oneshot::channel();
self.conversation_listeners
.insert(subscription_id, cancel_tx);
let outgoing_for_task = self.outgoing.clone();
let pending_interrupts = self.pending_interrupts.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = &mut cancel_rx => {
// User has unsubscribed, so exit this task.
break;
}
event = conversation.next_event() => {
let event = match event {
Ok(event) => event,
Err(err) => {
tracing::warn!("conversation.next_event() failed with: {err}");
break;
}
};
// For now, we send a notification for every event,
// JSON-serializing the `Event` as-is, but we will move
// to creating a special enum for notifications with a
// stable wire format.
let method = format!("codex/event/{}", event.msg);
let mut params = match serde_json::to_value(event.clone()) {
Ok(serde_json::Value::Object(map)) => map,
Ok(_) => {
tracing::error!("event did not serialize to an object");
continue;
}
Err(err) => {
tracing::error!("failed to serialize event: {err}");
continue;
}
};
params.insert("conversationId".to_string(), conversation_id.to_string().into());
outgoing_for_task.send_notification(OutgoingNotification {
method,
params: Some(params.into()),
})
.await;
apply_bespoke_event_handling(event.clone(), conversation_id, conversation.clone(), outgoing_for_task.clone(), pending_interrupts.clone()).await;
}
}
}
});
let response = AddConversationSubscriptionResponse { subscription_id };
self.outgoing.send_response(request_id, response).await;
}
async fn remove_conversation_listener(
&mut self,
request_id: RequestId,
params: RemoveConversationListenerParams,
) {
let RemoveConversationListenerParams { subscription_id } = params;
match self.conversation_listeners.remove(&subscription_id) {
Some(sender) => {
// Signal the spawned task to exit and acknowledge.
let _ = sender.send(());
let response = RemoveConversationSubscriptionResponse {};
self.outgoing.send_response(request_id, response).await;
}
None => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("subscription not found: {subscription_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
}
async fn git_diff_to_origin(&self, request_id: RequestId, cwd: PathBuf) {
let diff = git_diff_to_remote(&cwd).await;
match diff {
Some(value) => {
let response = GitDiffToRemoteResponse {
sha: value.sha,
diff: value.diff,
};
self.outgoing.send_response(request_id, response).await;
}
None => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("failed to compute git diff to remote for cwd: {cwd:?}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
}
#[allow(dead_code)]
async fn fuzzy_file_search(&mut self, request_id: RequestId, params: FuzzyFileSearchParams) {
let FuzzyFileSearchParams {
query,
roots,
cancellation_token,
} = params;
let cancel_flag = match cancellation_token.clone() {
Some(token) => {
let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await;
// if a cancellation_token is provided and a pending_request exists for
// that token, cancel it
if let Some(existing) = pending_fuzzy_searches.get(&token) {
existing.store(true, Ordering::Relaxed);
}
let flag = Arc::new(AtomicBool::new(false));
pending_fuzzy_searches.insert(token.clone(), flag.clone());
flag
}
None => Arc::new(AtomicBool::new(false)),
};
let results = match query.as_str() {
"" => vec![],
_ => run_fuzzy_file_search(query, roots, cancel_flag.clone()).await,
};
if let Some(token) = cancellation_token {
let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await;
if let Some(current_flag) = pending_fuzzy_searches.get(&token)
&& Arc::ptr_eq(current_flag, &cancel_flag)
{
pending_fuzzy_searches.remove(&token);
}
}
let response = FuzzyFileSearchResponse { files: results };
self.outgoing.send_response(request_id, response).await;
}
}
impl CodexMessageProcessor {
// Minimal compatibility layer: translate SendUserTurn into our current
// flow by submitting only the user items. We intentionally do not attempt
// per‑turn reconfiguration here (model, cwd, approval, sandbox) to avoid
// destabilizing the session. This preserves behavior and acks the request
// so clients using the new method continue to function.
async fn send_user_turn_compat(
&self,
request_id: RequestId,
params: SendUserTurnParams,
) {
let SendUserTurnParams {
conversation_id,
items,
..
} = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
// Map wire input items into core protocol items.
let mapped_items: Vec<CoreInputItem> = items
.into_iter()
.map(|item| match item {
WireInputItem::Text { text } => CoreInputItem::Text { text },
WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
})
.collect();
// Submit user input to the conversation.
let _ = conversation
.submit(Op::UserInput {
items: mapped_items,
})
.await;
// Acknowledge.
self.outgoing.send_response(request_id, SendUserTurnResponse {}).await;
}
}
async fn apply_bespoke_event_handling(
event: Event,
conversation_id: ConversationId,
conversation: Arc<CodexConversation>,
outgoing: Arc<OutgoingMessageSender>,
_pending_interrupts: Arc<Mutex<HashMap<Uuid, Vec<RequestId>>>>,
) {
let Event { id: _event_id, msg, .. } = event;
match msg {
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id,
changes,
reason,
grant_root,
}) => {
// Map core FileChange to wire FileChange
let file_changes: HashMap<PathBuf, code_protocol::protocol::FileChange> = changes
.into_iter()
.map(|(p, c)| {
let mapped = match c {
code_core::protocol::FileChange::Add { content } => {
code_protocol::protocol::FileChange::Add { content }
}
code_core::protocol::FileChange::Delete => {
code_protocol::protocol::FileChange::Delete { content: String::new() }
}
code_core::protocol::FileChange::Update {
unified_diff,
move_path,
original_content,
new_content,
} => {
code_protocol::protocol::FileChange::Update {
unified_diff,
move_path,
original_content,
new_content,
}
}
};
(p, mapped)
})
.collect();
let params = ApplyPatchApprovalParams {
conversation_id,
call_id: call_id.clone(),
file_changes,
reason,
grant_root,
};
let value = serde_json::to_value(&params).unwrap_or_default();
let rx = outgoing
.send_request(APPLY_PATCH_APPROVAL_METHOD, Some(value))
.await;
// TODO(mbolin): Enforce a timeout so this task does not live indefinitely?
let approval_id = call_id.clone(); // correlate by call_id, not event_id
tokio::spawn(async move {
on_patch_approval_response(approval_id, rx, conversation).await;
});
}
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
call_id,
command,
cwd,
reason,
}) => {
let params = ExecCommandApprovalParams {
conversation_id,
call_id: call_id.clone(),
command,
cwd,
reason,
};
let value = serde_json::to_value(&params).unwrap_or_default();
let rx = outgoing
.send_request(EXEC_COMMAND_APPROVAL_METHOD, Some(value))
.await;
// TODO(mbolin): Enforce a timeout so this task does not live indefinitely?
let approval_id = call_id.clone(); // correlate by call_id, not event_id
tokio::spawn(async move {
on_exec_approval_response(approval_id, rx, conversation).await;
});
}
// No special handling needed for interrupts; responses are sent immediately.
_ => {}
}
}
fn derive_config_from_params(
params: NewConversationParams,
code_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<Config> {
let NewConversationParams {
model,
profile,
cwd,
approval_policy,
sandbox: sandbox_mode,
config: cli_overrides,
base_instructions,
include_plan_tool,
..
} = params;
let overrides = ConfigOverrides {
model,
review_model: None,
config_profile: profile,
cwd: cwd.map(PathBuf::from),
approval_policy: approval_policy.map(map_ask_for_approval_from_wire),
sandbox_mode,
model_provider: None,
code_linux_sandbox_exe,
base_instructions,
include_plan_tool,
include_apply_patch_tool: None,
include_view_image_tool: None,
disable_response_storage: None,
show_raw_agent_reasoning: None,
debug: None,
tools_web_search_request: None,
mcp_servers: None,
experimental_client_tools: None,
};
let cli_overrides = cli_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v)))
.collect();
Config::load_with_cli_overrides(cli_overrides, overrides)
}
async fn on_patch_approval_response(
approval_id: String,
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
codex: Arc<CodexConversation>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Err(err) => {
error!("request failed: {err:?}");
if let Err(submit_err) = codex
.submit(Op::PatchApproval {
id: approval_id.clone(),
decision: core_protocol::ReviewDecision::Denied,
})
.await
{
error!("failed to submit denied PatchApproval after request failure: {submit_err}");
}
return;
}
};
let response =
serde_json::from_value::<ApplyPatchApprovalResponse>(value).unwrap_or_else(|err| {
error!("failed to deserialize ApplyPatchApprovalResponse: {err}");
ApplyPatchApprovalResponse {
decision: ReviewDecision::Denied,
}
});
if let Err(err) = codex
.submit(Op::PatchApproval {
id: approval_id,
decision: map_review_decision_from_wire(response.decision),
})
.await
{
error!("failed to submit PatchApproval: {err}");
}
}
async fn on_exec_approval_response(
approval_id: String,
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
conversation: Arc<CodexConversation>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Err(err) => {
tracing::error!("request failed: {err:?}");
return;
}
};
// Try to deserialize `value` and then make the appropriate call to `codex`.
let response =
serde_json::from_value::<ExecCommandApprovalResponse>(value).unwrap_or_else(|err| {
error!("failed to deserialize ExecCommandApprovalResponse: {err}");
// If we cannot deserialize the response, we deny the request to be
// conservative.
ExecCommandApprovalResponse {
decision: ReviewDecision::Denied,
}
});
if let Err(err) = conversation
.submit(Op::ExecApproval {
id: approval_id,
decision: map_review_decision_from_wire(response.decision),
})
.await
{
error!("failed to submit ExecApproval: {err}");
}
}
fn map_review_decision_from_wire(d: code_protocol::protocol::ReviewDecision) -> core_protocol::ReviewDecision {
match d {
code_protocol::protocol::ReviewDecision::Approved => core_protocol::ReviewDecision::Approved,
code_protocol::protocol::ReviewDecision::ApprovedForSession => core_protocol::ReviewDecision::ApprovedForSession,
code_protocol::protocol::ReviewDecision::Denied => core_protocol::ReviewDecision::Denied,
code_protocol::protocol::ReviewDecision::Abort => core_protocol::ReviewDecision::Abort,
}
}
fn map_ask_for_approval_from_wire(a: code_protocol::protocol::AskForApproval) -> core_protocol::AskForApproval {
match a {
code_protocol::protocol::AskForApproval::UnlessTrusted => core_protocol::AskForApproval::UnlessTrusted,
code_protocol::protocol::AskForApproval::OnFailure => core_protocol::AskForApproval::OnFailure,
code_protocol::protocol::AskForApproval::OnRequest => core_protocol::AskForApproval::OnRequest,
code_protocol::protocol::AskForApproval::Never => core_protocol::AskForApproval::Never,
}
}
// Unused legacy mappers removed to avoid warnings.
+2
View File
@@ -0,0 +1,2 @@
pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603;
@@ -0,0 +1,96 @@
use std::num::NonZero;
use std::num::NonZeroUsize;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use code_file_search as file_search;
use code_protocol::mcp_protocol::FuzzyFileSearchResult;
use tokio::task::JoinSet;
use tracing::warn;
#[allow(dead_code)]
const LIMIT_PER_ROOT: usize = 50;
#[allow(dead_code)]
const MAX_THREADS: usize = 12;
#[allow(dead_code)]
const COMPUTE_INDICES: bool = true;
#[allow(dead_code)]
pub(crate) async fn run_fuzzy_file_search(
query: String,
roots: Vec<String>,
cancellation_flag: Arc<AtomicBool>,
) -> Vec<FuzzyFileSearchResult> {
#[expect(clippy::expect_used)]
let limit_per_root =
NonZero::new(LIMIT_PER_ROOT).expect("LIMIT_PER_ROOT should be a valid non-zero usize");
let cores = std::thread::available_parallelism()
.map(std::num::NonZero::get)
.unwrap_or(1);
let threads = cores.min(MAX_THREADS);
let threads_per_root = (threads / roots.len()).max(1);
let threads = NonZero::new(threads_per_root).unwrap_or(NonZeroUsize::MIN);
let mut files: Vec<FuzzyFileSearchResult> = Vec::new();
let mut join_set = JoinSet::new();
for root in roots {
let search_dir = PathBuf::from(&root);
let query = query.clone();
let cancel_flag = cancellation_flag.clone();
join_set.spawn_blocking(move || {
match file_search::run(
query.as_str(),
limit_per_root,
&search_dir,
Vec::new(),
threads,
cancel_flag,
COMPUTE_INDICES,
) {
Ok(res) => Ok((root, res)),
Err(err) => Err((root, err)),
}
});
}
while let Some(res) = join_set.join_next().await {
match res {
Ok(Ok((root, res))) => {
for m in res.matches {
let path = m.path;
//TODO(shijie): Move file name generation to file_search lib.
let file_name = Path::new(&path)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone());
let result = FuzzyFileSearchResult {
root: root.clone(),
path,
file_name,
score: m.score,
indices: m.indices,
};
files.push(result);
}
}
Ok(Err((root, err))) => {
warn!("fuzzy-file-search in dir '{root}' failed: {err}");
}
Err(err) => {
warn!("fuzzy-file-search join_next failed: {err}");
}
}
}
files.sort_by(file_search::cmp_by_score_desc_then_path_asc::<
FuzzyFileSearchResult,
_,
_,
>(|f| f.score, |f| f.path.as_str()));
files
}
+140
View File
@@ -0,0 +1,140 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
use std::io::ErrorKind;
use std::io::Result as IoResult;
use std::path::PathBuf;
use code_common::CliConfigOverrides;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use mcp_types::JSONRPCMessage;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::io::{self};
use tokio::sync::mpsc;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing_subscriber::EnvFilter;
use crate::message_processor::MessageProcessor;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::OutgoingMessageSender;
pub mod code_message_processor;
mod error_code;
mod fuzzy_file_search;
mod message_processor;
pub mod outgoing_message;
/// Size of the bounded channels used to communicate between tasks. The value
/// is a balance between throughput and memory usage – 128 messages should be
/// plenty for an interactive CLI.
const CHANNEL_CAPACITY: usize = 128;
pub async fn run_main(
code_linux_sandbox_exe: Option<PathBuf>,
cli_config_overrides: CliConfigOverrides,
) -> IoResult<()> {
// Install a simple subscriber so `tracing` output is visible. Users can
// control the log level with `RUST_LOG`.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(EnvFilter::from_default_env())
.init();
// Set up channels.
let (incoming_tx, mut incoming_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
// Task: read from stdin, push to `incoming_tx`.
let stdin_reader_handle = tokio::spawn({
async move {
let stdin = io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await.unwrap_or_default() {
match serde_json::from_str::<JSONRPCMessage>(&line) {
Ok(msg) => {
if incoming_tx.send(msg).await.is_err() {
// Receiver gone – nothing left to do.
break;
}
}
Err(e) => error!("Failed to deserialize JSONRPCMessage: {e}"),
}
}
debug!("stdin reader finished (EOF)");
}
});
// Parse CLI overrides once and derive the base Config eagerly so later
// components do not need to work with raw TOML values.
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidInput,
format!("error parsing -c overrides: {e}"),
)
})?;
let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default())
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
// Task: process incoming messages.
let processor_handle = tokio::spawn({
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let mut processor = MessageProcessor::new(
outgoing_message_sender,
code_linux_sandbox_exe,
std::sync::Arc::new(config),
);
async move {
while let Some(msg) = incoming_rx.recv().await {
match msg {
JSONRPCMessage::Request(r) => processor.process_request(r).await,
JSONRPCMessage::Response(r) => processor.process_response(r).await,
JSONRPCMessage::Notification(n) => processor.process_notification(n).await,
JSONRPCMessage::Error(e) => processor.process_error(e),
}
}
info!("processor task exited (channel closed)");
}
});
// Task: write outgoing messages to stdout.
let stdout_writer_handle = tokio::spawn(async move {
let mut stdout = io::stdout();
while let Some(outgoing_message) = outgoing_rx.recv().await {
let msg: JSONRPCMessage = outgoing_message.into();
match serde_json::to_string(&msg) {
Ok(json) => {
if let Err(e) = stdout.write_all(json.as_bytes()).await {
error!("Failed to write to stdout: {e}");
break;
}
if let Err(e) = stdout.write_all(b"\n").await {
error!("Failed to write newline to stdout: {e}");
break;
}
}
Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"),
}
}
info!("stdout writer exited (channel closed)");
});
// Wait for all tasks to finish. The typical exit path is the stdin reader
// hitting EOF which, once it drops `incoming_tx`, propagates shutdown to
// the processor and then to the stdout task.
let _ = tokio::join!(stdin_reader_handle, processor_handle, stdout_writer_handle);
Ok(())
}
+10
View File
@@ -0,0 +1,10 @@
use code_app_server::run_main;
use code_arg0::arg0_dispatch_or_else;
use code_common::CliConfigOverrides;
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|code_linux_sandbox_exe| async move {
run_main(code_linux_sandbox_exe, CliConfigOverrides::default()).await?;
Ok(())
})
}
+147
View File
@@ -0,0 +1,147 @@
use std::path::PathBuf;
use crate::code_message_processor::CodexMessageProcessor;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use code_protocol::mcp_protocol::AuthMode;
use code_protocol::mcp_protocol::ClientInfo;
use code_protocol::mcp_protocol::ClientRequest;
use code_protocol::mcp_protocol::InitializeResponse;
use code_protocol::protocol::SessionSource;
use code_core::AuthManager;
use code_core::ConversationManager;
use code_core::config::Config;
use code_core::default_client::USER_AGENT_SUFFIX;
use code_core::default_client::get_code_user_agent;
use mcp_types::JSONRPCError;
use mcp_types::JSONRPCErrorError;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use std::sync::Arc;
pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
code_message_processor: CodexMessageProcessor,
base_config: Arc<Config>,
initialized: bool,
}
impl MessageProcessor {
/// Create a new `MessageProcessor`, retaining a handle to the outgoing
/// `Sender` so handlers can enqueue messages to be written to stdout.
pub(crate) fn new(
outgoing: OutgoingMessageSender,
code_linux_sandbox_exe: Option<PathBuf>,
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.code_home.clone(),
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Mcp,
));
let config_for_processor = config.clone();
let code_message_processor = CodexMessageProcessor::new(
auth_manager,
conversation_manager,
outgoing.clone(),
code_linux_sandbox_exe,
config_for_processor.clone(),
);
Self {
outgoing,
code_message_processor,
base_config: config_for_processor,
initialized: false,
}
}
pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) {
let request_id = request.id.clone();
if let Ok(request_json) = serde_json::to_value(request)
&& let Ok(code_request) = serde_json::from_value::<ClientRequest>(request_json)
{
match code_request {
// Handle Initialize internally so CodexMessageProcessor does not have to concern
// itself with the `initialized` bool.
ClientRequest::Initialize { request_id, params } => {
if self.initialized {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Already initialized".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
} else {
let ClientInfo {
name,
title: _title,
version,
} = params.client_info;
let user_agent_suffix = format!("{name}; {version}");
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
*suffix = Some(user_agent_suffix);
}
let user_agent = get_code_user_agent(Some(
&self.base_config.responses_originator_header,
));
let response = InitializeResponse { user_agent };
self.outgoing.send_response(request_id, response).await;
self.initialized = true;
return;
}
}
_ => {
if !self.initialized {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Not initialized".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
}
}
self.code_message_processor
.process_request(code_request)
.await;
} else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Invalid request".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) {
// Currently, we do not expect to receive any notifications from the
// client, so we just log them.
tracing::info!("<- notification: {:?}", notification);
}
/// Handle a standalone JSON-RPC response originating from the peer.
pub(crate) async fn process_response(&mut self, response: JSONRPCResponse) {
tracing::info!("<- response: {:?}", response);
let JSONRPCResponse { id, result, .. } = response;
self.outgoing.notify_client_response(id, result).await
}
/// Handle an error object received from the peer.
pub(crate) fn process_error(&mut self, err: JSONRPCError) {
tracing::error!("<- error: {:?}", err);
}
}
+179
View File
@@ -0,0 +1,179 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCError;
use mcp_types::JSONRPCErrorError;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use mcp_types::Result;
use serde::Serialize;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
/// Sends messages to the client and manages request callbacks.
pub struct OutgoingMessageSender {
next_request_id: AtomicI64,
sender: mpsc::UnboundedSender<OutgoingMessage>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
}
impl OutgoingMessageSender {
pub fn new(sender: mpsc::UnboundedSender<OutgoingMessage>) -> Self {
Self {
next_request_id: AtomicI64::new(0),
sender,
request_id_to_callback: Mutex::new(HashMap::new()),
}
}
pub async fn send_request(
&self,
method: &str,
params: Option<serde_json::Value>,
) -> oneshot::Receiver<Result> {
let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed));
let outgoing_message_id = id.clone();
let (tx_approve, rx_approve) = oneshot::channel();
{
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.insert(id, tx_approve);
}
let outgoing_message = OutgoingMessage::Request(OutgoingRequest {
id: outgoing_message_id,
method: method.to_string(),
params,
});
let _ = self.sender.send(outgoing_message);
rx_approve
}
pub async fn notify_client_response(&self, id: RequestId, result: Result) {
let entry = {
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.remove_entry(&id)
};
match entry {
Some((id, sender)) => {
if let Err(err) = sender.send(result) {
warn!("could not notify callback for {id:?} due to: {err:?}");
}
}
None => {
warn!("could not find callback for {id:?}");
}
}
}
pub async fn send_response<T: Serialize>(&self, id: RequestId, response: T) {
match serde_json::to_value(response) {
Ok(result) => {
let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result });
let _ = self.sender.send(outgoing_message);
}
Err(err) => {
self.send_error(
id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to serialize response: {err}"),
data: None,
},
)
.await;
}
}
}
/// All notifications should be migrated to [`ServerNotification`] and
/// [`OutgoingMessage::Notification`] should be removed.
pub async fn send_notification(&self, notification: OutgoingNotification) {
let outgoing_message = OutgoingMessage::Notification(notification);
let _ = self.sender.send(outgoing_message);
}
pub async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) {
let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error });
let _ = self.sender.send(outgoing_message);
}
}
/// Outgoing message from the server to the client.
pub enum OutgoingMessage {
Request(OutgoingRequest),
Notification(OutgoingNotification),
Response(OutgoingResponse),
Error(OutgoingError),
}
impl From<OutgoingMessage> for JSONRPCMessage {
fn from(val: OutgoingMessage) -> Self {
use OutgoingMessage::*;
match val {
Request(OutgoingRequest { id, method, params }) => {
JSONRPCMessage::Request(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id,
method,
params,
})
}
Notification(OutgoingNotification { method, params }) => {
JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method,
params,
})
}
Response(OutgoingResponse { id, result }) => {
JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id,
result,
})
}
Error(OutgoingError { id, error }) => JSONRPCMessage::Error(JSONRPCError {
jsonrpc: JSONRPC_VERSION.into(),
id,
error,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingRequest {
pub id: RequestId,
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingNotification {
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingResponse {
pub id: RequestId,
pub result: Result,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingError {
pub error: JSONRPCErrorError,
pub id: RequestId,
}
+3
View File
@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;
@@ -0,0 +1,21 @@
[package]
edition = "2024"
name = "app_test_support"
version = { workspace = true }
[lib]
path = "lib.rs"
[dependencies]
anyhow = { workspace = true }
assert_cmd = { workspace = true }
code-app-server-protocol = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
] }
wiremock = { workspace = true }
+17
View File
@@ -0,0 +1,17 @@
mod mcp_process;
mod mock_model_server;
mod responses;
use code_app_server_protocol::JSONRPCResponse;
pub use mcp_process::McpProcess;
pub use mock_model_server::create_mock_chat_completions_server;
pub use responses::create_apply_patch_sse_response;
pub use responses::create_final_assistant_message_sse_response;
pub use responses::create_shell_sse_response;
use serde::de::DeserializeOwned;
pub fn to_response<T: DeserializeOwned>(response: JSONRPCResponse) -> anyhow::Result<T> {
let value = serde_json::to_value(response.result)?;
let code_response = serde_json::from_value(value)?;
Ok(code_response)
}
@@ -0,0 +1,474 @@
use std::path::Path;
use std::process::Stdio;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::ChildStdin;
use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use code_app_server_protocol::AddConversationListenerParams;
use code_app_server_protocol::ArchiveConversationParams;
use code_app_server_protocol::CancelLoginChatGptParams;
use code_app_server_protocol::ClientInfo;
use code_app_server_protocol::ClientNotification;
use code_app_server_protocol::GetAuthStatusParams;
use code_app_server_protocol::InitializeParams;
use code_app_server_protocol::InterruptConversationParams;
use code_app_server_protocol::ListConversationsParams;
use code_app_server_protocol::LoginApiKeyParams;
use code_app_server_protocol::NewConversationParams;
use code_app_server_protocol::RemoveConversationListenerParams;
use code_app_server_protocol::ResumeConversationParams;
use code_app_server_protocol::SendUserMessageParams;
use code_app_server_protocol::SendUserTurnParams;
use code_app_server_protocol::ServerRequest;
use code_app_server_protocol::SetDefaultModelParams;
use code_app_server_protocol::JSONRPCError;
use code_app_server_protocol::JSONRPCMessage;
use code_app_server_protocol::JSONRPCNotification;
use code_app_server_protocol::JSONRPCRequest;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::RequestId;
use std::process::Command as StdCommand;
use tokio::process::Command;
pub struct McpProcess {
next_request_id: AtomicI64,
/// Retain this child process until the client is dropped. The Tokio runtime
/// will make a "best effort" to reap the process after it exits, but it is
/// not a guarantee. See the `kill_on_drop` documentation for details.
#[allow(dead_code)]
process: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl McpProcess {
pub async fn new(code_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env(code_home, &[]).await
}
/// Creates a new MCP process, allowing tests to override or remove
/// specific environment variables for the child process only.
///
/// Pass a tuple of (key, Some(value)) to set/override, or (key, None) to
/// remove a variable from the child's environment.
pub async fn new_with_env(
code_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
// Use assert_cmd to locate the binary path and then switch to tokio::process::Command
let std_cmd = StdCommand::cargo_bin("code-app-server")
.context("should find binary for codex-mcp-server")?;
let program = std_cmd.get_program().to_owned();
let mut cmd = Command::new(program);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.env("CODEX_HOME", code_home);
cmd.env("RUST_LOG", "debug");
for (k, v) in env_overrides {
match v {
Some(val) => {
cmd.env(k, val);
}
None => {
cmd.env_remove(k);
}
}
}
let mut process = cmd
.kill_on_drop(true)
.spawn()
.context("codex-mcp-server proc should start")?;
let stdin = process
.stdin
.take()
.ok_or_else(|| anyhow::format_err!("mcp should have stdin fd"))?;
let stdout = process
.stdout
.take()
.ok_or_else(|| anyhow::format_err!("mcp should have stdout fd"))?;
let stdout = BufReader::new(stdout);
// Forward child's stderr to our stderr so failures are visible even
// when stdout/stderr are captured by the test harness.
if let Some(stderr) = process.stderr.take() {
let mut stderr_reader = BufReader::new(stderr).lines();
tokio::spawn(async move {
while let Ok(Some(line)) = stderr_reader.next_line().await {
eprintln!("[mcp stderr] {line}");
}
});
}
Ok(Self {
next_request_id: AtomicI64::new(0),
process,
stdin,
stdout,
})
}
/// Performs the initialization handshake with the MCP server.
pub async fn initialize(&mut self) -> anyhow::Result<()> {
let params = Some(serde_json::to_value(InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
})?);
let req_id = self.send_request("initialize", params).await?;
let initialized = self.read_jsonrpc_message().await?;
let JSONRPCMessage::Response(response) = initialized else {
unreachable!("expected JSONRPCMessage::Response for initialize, got {initialized:?}");
};
if response.id != RequestId::Integer(req_id) {
anyhow::bail!(
"initialize response id mismatch: expected {}, got {:?}",
req_id,
response.id
);
}
// Send notifications/initialized to ack the response.
self.send_notification(ClientNotification::Initialized)
.await?;
Ok(())
}
/// Send a `newConversation` JSON-RPC request.
pub async fn send_new_conversation_request(
&mut self,
params: NewConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("newConversation", params).await
}
/// Send an `archiveConversation` JSON-RPC request.
pub async fn send_archive_conversation_request(
&mut self,
params: ArchiveConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("archiveConversation", params).await
}
/// Send an `addConversationListener` JSON-RPC request.
pub async fn send_add_conversation_listener_request(
&mut self,
params: AddConversationListenerParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("addConversationListener", params).await
}
/// Send a `sendUserMessage` JSON-RPC request with a single text item.
pub async fn send_send_user_message_request(
&mut self,
params: SendUserMessageParams,
) -> anyhow::Result<i64> {
// Wire format expects variants in camelCase; text item uses external tagging.
let params = Some(serde_json::to_value(params)?);
self.send_request("sendUserMessage", params).await
}
/// Send a `removeConversationListener` JSON-RPC request.
pub async fn send_remove_conversation_listener_request(
&mut self,
params: RemoveConversationListenerParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("removeConversationListener", params)
.await
}
/// Send a `sendUserTurn` JSON-RPC request.
pub async fn send_send_user_turn_request(
&mut self,
params: SendUserTurnParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("sendUserTurn", params).await
}
/// Send a `interruptConversation` JSON-RPC request.
pub async fn send_interrupt_conversation_request(
&mut self,
params: InterruptConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("interruptConversation", params).await
}
/// Send a `getAuthStatus` JSON-RPC request.
pub async fn send_get_auth_status_request(
&mut self,
params: GetAuthStatusParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("getAuthStatus", params).await
}
/// Send a `getUserSavedConfig` JSON-RPC request.
pub async fn send_get_user_saved_config_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getUserSavedConfig", None).await
}
/// Send a `getUserAgent` JSON-RPC request.
pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getUserAgent", None).await
}
/// Send a `userInfo` JSON-RPC request.
pub async fn send_user_info_request(&mut self) -> anyhow::Result<i64> {
self.send_request("userInfo", None).await
}
/// Send a `setDefaultModel` JSON-RPC request.
pub async fn send_set_default_model_request(
&mut self,
params: SetDefaultModelParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("setDefaultModel", params).await
}
/// Send a `listConversations` JSON-RPC request.
pub async fn send_list_conversations_request(
&mut self,
params: ListConversationsParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("listConversations", params).await
}
/// Send a `resumeConversation` JSON-RPC request.
pub async fn send_resume_conversation_request(
&mut self,
params: ResumeConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("resumeConversation", params).await
}
/// Send a `loginApiKey` JSON-RPC request.
pub async fn send_login_api_key_request(
&mut self,
params: LoginApiKeyParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("loginApiKey", params).await
}
/// Send a `loginChatGpt` JSON-RPC request.
pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
self.send_request("loginChatGpt", None).await
}
/// Send a `cancelLoginChatGpt` JSON-RPC request.
pub async fn send_cancel_login_chat_gpt_request(
&mut self,
params: CancelLoginChatGptParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("cancelLoginChatGpt", params).await
}
/// Send a `logoutChatGpt` JSON-RPC request.
pub async fn send_logout_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
self.send_request("logoutChatGpt", None).await
}
/// Send a `fuzzyFileSearch` JSON-RPC request.
pub async fn send_fuzzy_file_search_request(
&mut self,
query: &str,
roots: Vec<String>,
cancellation_token: Option<String>,
) -> anyhow::Result<i64> {
let mut params = serde_json::json!({
"query": query,
"roots": roots,
});
if let Some(token) = cancellation_token {
params["cancellationToken"] = serde_json::json!(token);
}
self.send_request("fuzzyFileSearch", Some(params)).await
}
async fn send_request(
&mut self,
method: &str,
params: Option<serde_json::Value>,
) -> anyhow::Result<i64> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let message = JSONRPCMessage::Request(JSONRPCRequest {
id: RequestId::Integer(request_id),
method: method.to_string(),
params,
});
self.send_jsonrpc_message(message).await?;
Ok(request_id)
}
pub async fn send_response(
&mut self,
id: RequestId,
result: serde_json::Value,
) -> anyhow::Result<()> {
self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse { id, result }))
.await
}
pub async fn send_notification(
&mut self,
notification: ClientNotification,
) -> anyhow::Result<()> {
let value = serde_json::to_value(notification)?;
self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification {
method: value
.get("method")
.and_then(|m| m.as_str())
.ok_or_else(|| anyhow::format_err!("notification missing method field"))?
.to_string(),
params: value.get("params").cloned(),
}))
.await
}
async fn send_jsonrpc_message(&mut self, message: JSONRPCMessage) -> anyhow::Result<()> {
eprintln!("writing message to stdin: {message:?}");
let payload = serde_json::to_string(&message)?;
self.stdin.write_all(payload.as_bytes()).await?;
self.stdin.write_all(b"\n").await?;
self.stdin.flush().await?;
Ok(())
}
async fn read_jsonrpc_message(&mut self) -> anyhow::Result<JSONRPCMessage> {
let mut line = String::new();
self.stdout.read_line(&mut line).await?;
let message = serde_json::from_str::<JSONRPCMessage>(&line)?;
eprintln!("read message from stdout: {message:?}");
Ok(message)
}
pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result<ServerRequest> {
eprintln!("in read_stream_until_request_message()");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(jsonrpc_request) => {
return jsonrpc_request.try_into().with_context(
|| "failed to deserialize ServerRequest from JSONRPCRequest",
);
}
JSONRPCMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}");
}
}
}
}
pub async fn read_stream_until_response_message(
&mut self,
request_id: RequestId,
) -> anyhow::Result<JSONRPCResponse> {
eprintln!("in read_stream_until_response_message({request_id:?})");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
JSONRPCMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(jsonrpc_response) => {
if jsonrpc_response.id == request_id {
return Ok(jsonrpc_response);
}
}
}
}
}
pub async fn read_stream_until_error_message(
&mut self,
request_id: RequestId,
) -> anyhow::Result<JSONRPCError> {
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(_) => {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
JSONRPCMessage::Response(_) => {
// Keep scanning; we're waiting for an error with matching id.
}
JSONRPCMessage::Error(err) => {
if err.id == request_id {
return Ok(err);
}
}
}
}
}
pub async fn read_stream_until_notification_message(
&mut self,
method: &str,
) -> anyhow::Result<JSONRPCNotification> {
eprintln!("in read_stream_until_notification_message({method})");
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(notification) => {
if notification.method == method {
return Ok(notification);
}
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
JSONRPCMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
}
JSONRPCMessage::Response(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}");
}
}
}
}
}
@@ -0,0 +1,47 @@
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::Respond;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
/// Create a mock server that will provide the responses, in order, for
/// requests to the `/v1/chat/completions` endpoint.
pub async fn create_mock_chat_completions_server(responses: Vec<String>) -> MockServer {
let server = MockServer::start().await;
let num_calls = responses.len();
let seq_responder = SeqResponder {
num_calls: AtomicUsize::new(0),
responses,
};
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(seq_responder)
.expect(num_calls as u64)
.mount(&server)
.await;
server
}
struct SeqResponder {
num_calls: AtomicUsize,
responses: Vec<String>,
}
impl Respond for SeqResponder {
fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
let call_num = self.num_calls.fetch_add(1, Ordering::SeqCst);
match self.responses.get(call_num) {
Some(response) => ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(response.clone(), "text/event-stream"),
None => panic!("no response for {call_num}"),
}
}
}
@@ -0,0 +1,95 @@
use serde_json::json;
use std::path::Path;
pub fn create_shell_sse_response(
command: Vec<String>,
workdir: Option<&Path>,
timeout_ms: Option<u64>,
call_id: &str,
) -> anyhow::Result<String> {
// The `arguments`` for the `shell` tool is a serialized JSON object.
let tool_call_arguments = serde_json::to_string(&json!({
"command": command,
"workdir": workdir.map(|w| w.to_string_lossy()),
"timeout": timeout_ms
}))?;
let tool_call = json!({
"choices": [
{
"delta": {
"tool_calls": [
{
"id": call_id,
"function": {
"name": "shell",
"arguments": tool_call_arguments
}
}
]
},
"finish_reason": "tool_calls"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&tool_call)?
);
Ok(sse)
}
pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result<String> {
let assistant_message = json!({
"choices": [
{
"delta": {
"content": message
},
"finish_reason": "stop"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&assistant_message)?
);
Ok(sse)
}
pub fn create_apply_patch_sse_response(
patch_content: &str,
call_id: &str,
) -> anyhow::Result<String> {
// Use shell command to call apply_patch with heredoc format
let shell_command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF");
let tool_call_arguments = serde_json::to_string(&json!({
"command": ["bash", "-lc", shell_command]
}))?;
let tool_call = json!({
"choices": [
{
"delta": {
"tool_calls": [
{
"id": call_id,
"function": {
"name": "shell",
"arguments": tool_call_arguments
}
}
]
},
"finish_reason": "tool_calls"
}
]
});
let sse = format!(
"data: {}\n\ndata: DONE\n\n",
serde_json::to_string(&tool_call)?
);
Ok(sse)
}
@@ -0,0 +1,105 @@
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use code_app_server_protocol::ArchiveConversationParams;
use code_app_server_protocol::ArchiveConversationResponse;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::NewConversationParams;
use code_app_server_protocol::NewConversationResponse;
use code_app_server_protocol::RequestId;
use code_core::ARCHIVED_SESSIONS_SUBDIR;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn archive_conversation_moves_rollout_into_archived_directory() {
let code_home = TempDir::new().expect("create temp dir");
create_config_toml(code_home.path()).expect("write config.toml");
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("initialize timeout")
.expect("initialize request");
let new_request_id = mcp
.send_new_conversation_request(NewConversationParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await
.expect("send newConversation");
let new_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_request_id)),
)
.await
.expect("newConversation timeout")
.expect("newConversation response");
let NewConversationResponse {
conversation_id,
rollout_path,
..
} = to_response::<NewConversationResponse>(new_response)
.expect("deserialize newConversation response");
assert!(
rollout_path.exists(),
"expected rollout path {} to exist",
rollout_path.display()
);
let archive_request_id = mcp
.send_archive_conversation_request(ArchiveConversationParams {
conversation_id,
rollout_path: rollout_path.clone(),
})
.await
.expect("send archiveConversation");
let archive_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(archive_request_id)),
)
.await
.expect("archiveConversation timeout")
.expect("archiveConversation response");
let _: ArchiveConversationResponse =
to_response::<ArchiveConversationResponse>(archive_response)
.expect("deserialize archiveConversation response");
let archived_directory = code_home.path().join(ARCHIVED_SESSIONS_SUBDIR);
let archived_rollout_path =
archived_directory.join(rollout_path.file_name().unwrap_or_else(|| {
panic!("rollout path {} missing file name", rollout_path.display())
}));
assert!(
!rollout_path.exists(),
"expected rollout path {} to be moved",
rollout_path.display()
);
assert!(
archived_rollout_path.exists(),
"expected archived rollout path {} to exist",
archived_rollout_path.display()
);
}
fn create_config_toml(code_home: &Path) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(config_toml, config_contents())
}
fn config_contents() -> &'static str {
r#"model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
"#
}
+201
View File
@@ -0,0 +1,201 @@
use std::path::Path;
use code_core::auth::login_with_api_key;
use code_app_server_protocol::AuthMode;
use code_app_server_protocol::GetAuthStatusParams;
use code_app_server_protocol::GetAuthStatusResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn create_config_toml_custom_provider(
code_home: &Path,
requires_openai_auth: bool,
) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
let requires_line = if requires_openai_auth {
"requires_openai_auth = true\n"
} else {
""
};
let contents = format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "http://127.0.0.1:0/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
{requires_line}
"#
);
std::fs::write(config_toml, contents)
}
fn create_config_toml(code_home: &Path) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(
config_toml,
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
"#,
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_no_auth() {
let code_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(code_home.path()).expect("write config.toml");
let mut mcp = McpProcess::new_with_env(code_home.path(), &[("OPENAI_API_KEY", None)])
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await
.expect("send getAuthStatus");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, None, "expected no auth method");
assert_eq!(status.auth_token, None, "expected no token");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_with_api_key() {
let code_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(code_home.path()).expect("write config.toml");
login_with_api_key(code_home.path(), "sk-test-key").expect("seed api key");
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await
.expect("send getAuthStatus");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
assert_eq!(status.auth_token, Some("sk-test-key".to_string()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_with_api_key_when_auth_not_required() {
let code_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml_custom_provider(code_home.path(), false)
.unwrap_or_else(|err| panic!("write config.toml: {err}"));
login_with_api_key(code_home.path(), "sk-test-key").expect("seed api key");
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let request_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await
.expect("send getAuthStatus");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, None, "expected no auth method");
assert_eq!(status.auth_token, None, "expected no token");
assert_eq!(
status.requires_openai_auth,
Some(false),
"requires_openai_auth should be false",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_auth_status_with_api_key_no_include_token() {
let code_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(code_home.path()).expect("write config.toml");
login_with_api_key(code_home.path(), "sk-test-key").expect("seed api key");
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
// Build params via struct so None field is omitted in wire JSON.
let params = GetAuthStatusParams {
include_token: None,
refresh_token: Some(false),
};
let request_id = mcp
.send_get_auth_status_request(params)
.await
.expect("send getAuthStatus");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
assert!(status.auth_token.is_none(), "token must be omitted");
}
@@ -0,0 +1,369 @@
use std::path::Path;
use code_protocol::protocol::AskForApproval;
use code_protocol::protocol::SandboxPolicy;
use code_core::protocol_config_types::ReasoningEffort;
use code_core::protocol_config_types::ReasoningSummary;
use code_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use pretty_assertions::assert_eq;
use std::env;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_code_jsonrpc_conversation_flow() {
if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
let tmp = TempDir::new().expect("tmp dir");
// Temporary Codex home with config pointing at the mock server.
let code_home = tmp.path().join("code_home");
std::fs::create_dir(&code_home).expect("create codex home dir");
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory).expect("create working directory");
// Create a mock model server that immediately ends each turn.
// Two turns are expected: initial session configure + one user message.
let responses = vec![
create_shell_sse_response(
vec!["ls".to_string()],
Some(&working_directory),
Some(5000),
"call1234",
)
.expect("create shell sse response"),
create_final_assistant_message_sse_response("Enjoy your new git repo!")
.expect("create final assistant message"),
];
let server = create_mock_chat_completions_server(responses).await;
create_config_toml(&code_home, &server.uri()).expect("write config");
// Start MCP server and initialize.
let mut mcp = McpProcess::new(&code_home).await.expect("spawn mcp");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init error");
// 1) newConversation
let new_conv_id = mcp
.send_new_conversation_request(NewConversationParams {
cwd: Some(working_directory.to_string_lossy().into_owned()),
..Default::default()
})
.await
.expect("send newConversation");
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await
.expect("newConversation timeout")
.expect("newConversation resp");
let new_conv_resp = to_response::<NewConversationResponse>(new_conv_resp)
.expect("deserialize newConversation response");
let NewConversationResponse {
conversation_id,
model,
..
} = new_conv_resp;
assert_eq!(model, "mock-model");
// 2) addConversationListener
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
let add_listener_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
)
.await
.expect("addConversationListener timeout")
.expect("addConversationListener resp");
let AddConversationSubscriptionResponse { subscription_id } =
to_response::<AddConversationSubscriptionResponse>(add_listener_resp)
.expect("deserialize addConversationListener response");
// 3) sendUserMessage (should trigger notifications; we only validate an OK response)
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![code_app_server_protocol::InputItem::Text {
text: "text".to_string(),
}],
})
.await
.expect("send sendUserMessage");
let send_user_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)),
)
.await
.expect("sendUserMessage timeout")
.expect("sendUserMessage resp");
let SendUserMessageResponse {} = to_response::<SendUserMessageResponse>(send_user_resp)
.expect("deserialize sendUserMessage response");
// Verify the task_finished notification is received.
// Note this also ensures that the final request to the server was made.
let task_finished_notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await
.expect("task_finished_notification timeout")
.expect("task_finished_notification resp");
let serde_json::Value::Object(map) = task_finished_notification
.params
.expect("notification should have params")
else {
panic!("task_finished_notification should have params");
};
assert_eq!(
map.get("conversationId")
.expect("should have conversationId"),
&serde_json::Value::String(conversation_id.to_string())
);
// 4) removeConversationListener
let remove_listener_id = mcp
.send_remove_conversation_listener_request(RemoveConversationListenerParams {
subscription_id,
})
.await
.expect("send removeConversationListener");
let remove_listener_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(remove_listener_id)),
)
.await
.expect("removeConversationListener timeout")
.expect("removeConversationListener resp");
let RemoveConversationSubscriptionResponse {} =
to_response(remove_listener_resp).expect("deserialize removeConversationListener response");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_send_user_turn_changes_approval_policy_behavior() {
if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
let tmp = TempDir::new().expect("tmp dir");
let code_home = tmp.path().join("code_home");
std::fs::create_dir(&code_home).expect("create codex home dir");
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory).expect("create working directory");
// Mock server will request a python shell call for the first and second turn, then finish.
let responses = vec![
create_shell_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
Some(&working_directory),
Some(5000),
"call1",
)
.expect("create first shell sse response"),
create_final_assistant_message_sse_response("done 1")
.expect("create final assistant message 1"),
create_shell_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
Some(&working_directory),
Some(5000),
"call2",
)
.expect("create second shell sse response"),
create_final_assistant_message_sse_response("done 2")
.expect("create final assistant message 2"),
];
let server = create_mock_chat_completions_server(responses).await;
create_config_toml(&code_home, &server.uri()).expect("write config");
// Start MCP server and initialize.
let mut mcp = McpProcess::new(&code_home).await.expect("spawn mcp");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init error");
// 1) Start conversation with approval_policy=untrusted
let new_conv_id = mcp
.send_new_conversation_request(NewConversationParams {
cwd: Some(working_directory.to_string_lossy().into_owned()),
..Default::default()
})
.await
.expect("send newConversation");
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await
.expect("newConversation timeout")
.expect("newConversation resp");
let NewConversationResponse {
conversation_id, ..
} = to_response::<NewConversationResponse>(new_conv_resp)
.expect("deserialize newConversation response");
// 2) addConversationListener
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
let _: AddConversationSubscriptionResponse =
to_response::<AddConversationSubscriptionResponse>(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
)
.await
.expect("addConversationListener timeout")
.expect("addConversationListener resp"),
)
.expect("deserialize addConversationListener response");
// 3) sendUserMessage triggers a shell call; approval policy is Untrusted so we should get an elicitation
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![code_app_server_protocol::InputItem::Text {
text: "run python".to_string(),
}],
})
.await
.expect("send sendUserMessage");
let _send_user_resp: SendUserMessageResponse = to_response::<SendUserMessageResponse>(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)),
)
.await
.expect("sendUserMessage timeout")
.expect("sendUserMessage resp"),
)
.expect("deserialize sendUserMessage response");
// Expect an ExecCommandApproval request (elicitation)
let request = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await
.expect("waiting for exec approval request timeout")
.expect("exec approval request");
let ServerRequest::ExecCommandApproval { request_id, params } = request else {
panic!("expected ExecCommandApproval request, got: {request:?}");
};
assert_eq!(
ExecCommandApprovalParams {
conversation_id,
call_id: "call1".to_string(),
command: vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
cwd: working_directory.clone(),
reason: None,
},
params
);
// Approve so the first turn can complete
mcp.send_response(
request_id,
serde_json::json!({ "decision": code_core::protocol::ReviewDecision::Approved }),
)
.await
.expect("send approval response");
// Wait for first TaskComplete
let _ = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await
.expect("task_complete 1 timeout")
.expect("task_complete 1 notification");
// 4) sendUserTurn with approval_policy=never should run without elicitation
let send_turn_id = mcp
.send_send_user_turn_request(SendUserTurnParams {
conversation_id,
items: vec![code_app_server_protocol::InputItem::Text {
text: "run python again".to_string(),
}],
cwd: working_directory.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: "mock-model".to_string(),
effort: Some(ReasoningEffort::Medium),
summary: ReasoningSummary::Auto,
})
.await
.expect("send sendUserTurn");
// Acknowledge sendUserTurn
let _send_turn_resp: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)),
)
.await
.expect("sendUserTurn timeout")
.expect("sendUserTurn resp"),
)
.expect("deserialize sendUserTurn response");
// Ensure we do NOT receive an ExecCommandApproval request before the task completes.
// If any Request is seen while waiting for task_complete, the helper will error and the test fails.
let _ = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await
.expect("task_complete 2 timeout")
.expect("task_complete 2 notification");
}
// Helper: minimal config.toml pointing at mock provider.
fn create_config_toml(code_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "untrusted"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
+164
View File
@@ -0,0 +1,164 @@
use std::collections::HashMap;
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use code_app_server_protocol::GetUserSavedConfigResponse;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::Profile;
use code_app_server_protocol::RequestId;
use code_app_server_protocol::SandboxSettings;
use code_app_server_protocol::Tools;
use code_app_server_protocol::UserSavedConfig;
use code_core::protocol::AskForApproval;
use code_protocol::config_types::ReasoningEffort;
use code_protocol::config_types::ReasoningSummary;
use code_protocol::config_types::SandboxMode;
use code_protocol::config_types::Verbosity;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn create_config_toml(code_home: &Path) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(
config_toml,
r#"
model = "gpt-5-codex"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
model_reasoning_summary = "detailed"
model_reasoning_effort = "high"
model_verbosity = "medium"
profile = "test"
[sandbox_workspace_write]
writable_roots = ["/tmp"]
network_access = true
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
[tools]
web_search = false
view_image = true
[profiles.test]
model = "gpt-4o"
approval_policy = "on-request"
model_reasoning_effort = "high"
model_reasoning_summary = "detailed"
model_verbosity = "medium"
model_provider = "openai"
chatgpt_base_url = "https://api.chatgpt.com"
"#,
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn get_config_toml_parses_all_fields() {
let code_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(code_home.path()).expect("write config.toml");
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let request_id = mcp
.send_get_user_saved_config_request()
.await
.expect("send getUserSavedConfig");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getUserSavedConfig timeout")
.expect("getUserSavedConfig response");
let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config");
let expected = GetUserSavedConfigResponse {
config: UserSavedConfig {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
sandbox_settings: Some(SandboxSettings {
writable_roots: vec!["/tmp".into()],
network_access: Some(true),
exclude_tmpdir_env_var: Some(true),
exclude_slash_tmp: Some(true),
}),
model: Some("gpt-5-codex".into()),
model_reasoning_effort: Some(ReasoningEffort::High),
model_reasoning_summary: Some(ReasoningSummary::Detailed),
model_verbosity: Some(Verbosity::Medium),
tools: Some(Tools {
web_search: Some(false),
view_image: Some(true),
}),
profile: Some("test".to_string()),
profiles: HashMap::from([(
"test".into(),
Profile {
model: Some("gpt-4o".into()),
approval_policy: Some(AskForApproval::OnRequest),
model_reasoning_effort: Some(ReasoningEffort::High),
model_reasoning_summary: Some(ReasoningSummary::Detailed),
model_verbosity: Some(Verbosity::Medium),
model_provider: Some("openai".into()),
chatgpt_base_url: Some("https://api.chatgpt.com".into()),
},
)]),
},
};
assert_eq!(config, expected);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_config_toml_empty() {
let code_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let request_id = mcp
.send_get_user_saved_config_request()
.await
.expect("send getUserSavedConfig");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getUserSavedConfig timeout")
.expect("getUserSavedConfig response");
let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config");
let expected = GetUserSavedConfigResponse {
config: UserSavedConfig {
approval_policy: None,
sandbox_mode: None,
sandbox_settings: None,
model: None,
model_reasoning_effort: None,
model_reasoning_summary: None,
model_verbosity: None,
tools: None,
profile: None,
profiles: HashMap::new(),
},
};
assert_eq!(config, expected);
}
@@ -0,0 +1,156 @@
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::to_response;
use code_app_server_protocol::AddConversationListenerParams;
use code_app_server_protocol::AddConversationSubscriptionResponse;
use code_app_server_protocol::InputItem;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::NewConversationParams;
use code_app_server_protocol::NewConversationResponse;
use code_app_server_protocol::RequestId;
use code_app_server_protocol::SendUserMessageParams;
use code_app_server_protocol::SendUserMessageResponse;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_conversation_create_and_send_message_ok() {
// Mock server – we won't strictly rely on it, but provide one to satisfy any model wiring.
let responses = vec![
create_final_assistant_message_sse_response("Done").expect("build mock assistant message"),
];
let server = create_mock_chat_completions_server(responses).await;
// Temporary Codex home with config pointing at the mock server.
let code_home = TempDir::new().expect("create temp dir");
create_config_toml(code_home.path(), &server.uri()).expect("write config.toml");
// Start MCP server process and initialize.
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
// Create a conversation via the new JSON-RPC API.
let new_conv_id = mcp
.send_new_conversation_request(NewConversationParams {
model: Some("o3".to_string()),
..Default::default()
})
.await
.expect("send newConversation");
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await
.expect("newConversation timeout")
.expect("newConversation resp");
let NewConversationResponse {
conversation_id,
model,
..
} = to_response::<NewConversationResponse>(new_conv_resp)
.expect("deserialize newConversation response");
assert_eq!(model, "o3");
// Add a listener so we receive notifications for this conversation (not strictly required for this test).
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
let _sub: AddConversationSubscriptionResponse =
to_response::<AddConversationSubscriptionResponse>(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
)
.await
.expect("addConversationListener timeout")
.expect("addConversationListener resp"),
)
.expect("deserialize addConversationListener response");
// Now send a user message via the wire API and expect an OK (empty object) result.
let send_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![InputItem::Text {
text: "Hello".to_string(),
}],
})
.await
.expect("send sendUserMessage");
let send_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_id)),
)
.await
.expect("sendUserMessage timeout")
.expect("sendUserMessage resp");
let _ok: SendUserMessageResponse = to_response::<SendUserMessageResponse>(send_resp)
.expect("deserialize sendUserMessage response");
// avoid race condition by waiting for the mock server to receive the chat.completions request
let deadline = std::time::Instant::now() + DEFAULT_READ_TIMEOUT;
loop {
let requests = server.received_requests().await.unwrap_or_default();
if !requests.is_empty() {
break;
}
if std::time::Instant::now() >= deadline {
panic!("mock server did not receive the chat.completions request in time");
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
// Verify the outbound request body matches expectations for Chat Completions.
let request = &server.received_requests().await.unwrap()[0];
let body = request
.body_json::<serde_json::Value>()
.expect("parse request body as JSON");
assert_eq!(body["model"], json!("o3"));
assert!(body["stream"].as_bool().unwrap_or(false));
let messages = body["messages"]
.as_array()
.expect("messages should be array");
let last = messages.last().expect("at least one message");
assert_eq!(last["role"], json!("user"));
assert_eq!(last["content"], json!("Hello"));
drop(server);
}
// Helper to create a config.toml pointing at the mock model server.
fn create_config_toml(code_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
@@ -0,0 +1,146 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::McpProcess;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> {
// Prepare a temporary Codex home and a separate root with test files.
let code_home = TempDir::new().context("create temp codex home")?;
let root = TempDir::new().context("create temp search root")?;
// Create files designed to have deterministic ordering for query "abe".
std::fs::write(root.path().join("abc"), "x").context("write file abc")?;
std::fs::write(root.path().join("abcde"), "x").context("write file abcde")?;
std::fs::write(root.path().join("abexy"), "x").context("write file abexy")?;
std::fs::write(root.path().join("zzz.txt"), "x").context("write file zzz")?;
let sub_dir = root.path().join("sub");
std::fs::create_dir_all(&sub_dir).context("create sub dir")?;
let sub_abce_path = sub_dir.join("abce");
std::fs::write(&sub_abce_path, "x").context("write file sub/abce")?;
let sub_abce_rel = sub_abce_path
.strip_prefix(root.path())
.context("strip root prefix from sub/abce")?
.to_string_lossy()
.to_string();
// Start MCP server and initialize.
let mut mcp = McpProcess::new(code_home.path())
.await
.context("spawn mcp")?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.context("init timeout")?
.context("init failed")?;
let root_path = root.path().to_string_lossy().to_string();
// Send fuzzyFileSearch request.
let request_id = mcp
.send_fuzzy_file_search_request("abe", vec![root_path.clone()], None)
.await
.context("send fuzzyFileSearch")?;
// Read response and verify shape and ordering.
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.context("fuzzyFileSearch timeout")?
.context("fuzzyFileSearch resp")?;
let value = resp.result;
// The path separator on Windows affects the score.
let expected_score = if cfg!(windows) { 69 } else { 72 };
assert_eq!(
value,
json!({
"files": [
{
"root": root_path.clone(),
"path": "abexy",
"file_name": "abexy",
"score": 88,
"indices": [0, 1, 2],
},
{
"root": root_path.clone(),
"path": "abcde",
"file_name": "abcde",
"score": 74,
"indices": [0, 1, 4],
},
{
"root": root_path.clone(),
"path": sub_abce_rel,
"file_name": "abce",
"score": expected_score,
"indices": [4, 5, 7],
},
]
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> {
let code_home = TempDir::new().context("create temp codex home")?;
let root = TempDir::new().context("create temp search root")?;
std::fs::write(root.path().join("alpha.txt"), "contents").context("write alpha")?;
let mut mcp = McpProcess::new(code_home.path())
.await
.context("spawn mcp")?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.context("init timeout")?
.context("init failed")?;
let root_path = root.path().to_string_lossy().to_string();
let request_id = mcp
.send_fuzzy_file_search_request("alp", vec![root_path.clone()], None)
.await
.context("send fuzzyFileSearch")?;
let request_id_2 = mcp
.send_fuzzy_file_search_request(
"alp",
vec![root_path.clone()],
Some(request_id.to_string()),
)
.await
.context("send fuzzyFileSearch")?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id_2)),
)
.await
.context("fuzzyFileSearch timeout")?
.context("fuzzyFileSearch resp")?;
let files = resp
.result
.get("files")
.context("files key missing")?
.as_array()
.context("files not array")?
.clone();
assert_eq!(files.len(), 1);
assert_eq!(files[0]["root"], root_path);
assert_eq!(files[0]["path"], "alpha.txt");
Ok(())
}
+158
View File
@@ -0,0 +1,158 @@
#![cfg(unix)]
// Support code lives in the `app_test_support` crate under tests/common.
use std::path::Path;
use code_protocol::protocol::TurnAbortReason;
use code_app_server_protocol::AddConversationListenerParams;
use code_app_server_protocol::InterruptConversationParams;
use code_app_server_protocol::InterruptConversationResponse;
use code_app_server_protocol::NewConversationParams;
use code_app_server_protocol::NewConversationResponse;
use code_app_server_protocol::SendUserMessageParams;
use code_app_server_protocol::SendUserMessageResponse;
use core_test_support::non_sandbox_test;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
use tokio::time::timeout;
use app_test_support::McpProcess;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::create_shell_sse_response;
use app_test_support::to_response;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_shell_command_interruption() {
non_sandbox_test!();
if let Err(err) = shell_command_interruption().await {
panic!("failure: {err}");
}
}
async fn shell_command_interruption() -> anyhow::Result<()> {
// Use a cross-platform blocking command. On Windows plain `sleep` is not guaranteed to exist
// (MSYS/GNU coreutils may be absent) and the failure causes the tool call to finish immediately,
// which triggers a second model request before the test sends the explicit follow-up. That
// prematurely consumes the second mocked SSE response and leads to a third POST (panic: no response for 2).
// Powershell Start-Sleep is always available on Windows runners. On Unix we keep using `sleep`.
#[cfg(target_os = "windows")]
let shell_command = vec![
"powershell".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 10".to_string(),
];
#[cfg(not(target_os = "windows"))]
let shell_command = vec!["sleep".to_string(), "10".to_string()];
let tmp = TempDir::new()?;
// Temporary Codex home with config pointing at the mock server.
let code_home = tmp.path().join("code_home");
std::fs::create_dir(&code_home)?;
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
// Create mock server with a single SSE response: the long sleep command
let server = create_mock_chat_completions_server(vec![create_shell_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000), // 10 seconds timeout in ms
"call_sleep",
)?])
.await;
create_config_toml(&code_home, server.uri())?;
// Start MCP server and initialize.
let mut mcp = McpProcess::new(&code_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
// 1) newConversation
let new_conv_id = mcp
.send_new_conversation_request(NewConversationParams {
cwd: Some(working_directory.to_string_lossy().into_owned()),
..Default::default()
})
.await?;
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await??;
let new_conv_resp = to_response::<NewConversationResponse>(new_conv_resp)?;
let NewConversationResponse {
conversation_id, ..
} = new_conv_resp;
// 2) addConversationListener
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await?;
let _add_listener_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
)
.await??;
// 3) sendUserMessage (should trigger notifications; we only validate an OK response)
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![code_app_server_protocol::InputItem::Text {
text: "run first sleep command".to_string(),
}],
})
.await?;
let send_user_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)),
)
.await??;
let SendUserMessageResponse {} = to_response::<SendUserMessageResponse>(send_user_resp)?;
// Give the command a moment to start
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// 4) send interrupt request
let interrupt_id = mcp
.send_interrupt_conversation_request(InterruptConversationParams { conversation_id })
.await?;
let interrupt_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(interrupt_id)),
)
.await??;
let InterruptConversationResponse { abort_reason } =
to_response::<InterruptConversationResponse>(interrupt_resp)?;
assert_eq!(TurnAbortReason::Interrupted, abort_reason);
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn create_config_toml(code_home: &Path, server_uri: String) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
@@ -0,0 +1,179 @@
use std::fs;
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use code_app_server_protocol::JSONRPCNotification;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::ListConversationsParams;
use code_app_server_protocol::ListConversationsResponse;
use code_app_server_protocol::NewConversationParams; // reused for overrides shape
use code_app_server_protocol::RequestId;
use code_app_server_protocol::ResumeConversationParams;
use code_app_server_protocol::ResumeConversationResponse;
use code_app_server_protocol::ServerNotification;
use code_app_server_protocol::SessionConfiguredNotification;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_list_and_resume_conversations() {
// Prepare a temporary CODEX_HOME with a few fake rollout files.
let code_home = TempDir::new().expect("create temp dir");
create_fake_rollout(
code_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"Hello A",
);
create_fake_rollout(
code_home.path(),
"2025-01-01T13-00-00",
"2025-01-01T13:00:00Z",
"Hello B",
);
create_fake_rollout(
code_home.path(),
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Hello C",
);
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
// Request first page with size 2
let req_id = mcp
.send_list_conversations_request(ListConversationsParams {
page_size: Some(2),
cursor: None,
})
.await
.expect("send listConversations");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await
.expect("listConversations timeout")
.expect("listConversations resp");
let ListConversationsResponse { items, next_cursor } =
to_response::<ListConversationsResponse>(resp).expect("deserialize response");
assert_eq!(items.len(), 2);
// Newest first; preview text should match
assert_eq!(items[0].preview, "Hello A");
assert_eq!(items[1].preview, "Hello B");
assert!(items[0].path.is_absolute());
assert!(next_cursor.is_some());
// Request the next page using the cursor
let req_id2 = mcp
.send_list_conversations_request(ListConversationsParams {
page_size: Some(2),
cursor: next_cursor,
})
.await
.expect("send listConversations page 2");
let resp2: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id2)),
)
.await
.expect("listConversations page 2 timeout")
.expect("listConversations page 2 resp");
let ListConversationsResponse {
items: items2,
next_cursor: next2,
..
} = to_response::<ListConversationsResponse>(resp2).expect("deserialize response");
assert_eq!(items2.len(), 1);
assert_eq!(items2[0].preview, "Hello C");
assert!(next2.is_some());
// Now resume one of the sessions and expect a SessionConfigured notification and response.
let resume_req_id = mcp
.send_resume_conversation_request(ResumeConversationParams {
path: items[0].path.clone(),
overrides: Some(NewConversationParams {
model: Some("o3".to_string()),
..Default::default()
}),
})
.await
.expect("send resumeConversation");
// Expect a codex/event notification with msg.type == sessionConfigured
let notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("sessionConfigured"),
)
.await
.expect("sessionConfigured notification timeout")
.expect("sessionConfigured notification");
let session_configured: ServerNotification = notification
.try_into()
.expect("deserialize sessionConfigured notification");
// Basic shape assertion: ensure event type is sessionConfigured
let ServerNotification::SessionConfigured(SessionConfiguredNotification {
model,
rollout_path,
..
}) = session_configured
else {
unreachable!("expected sessionConfigured notification");
};
assert_eq!(model, "o3");
assert_eq!(items[0].path.clone(), rollout_path);
// Then the response for resumeConversation
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_req_id)),
)
.await
.expect("resumeConversation timeout")
.expect("resumeConversation resp");
let ResumeConversationResponse {
conversation_id, ..
} = to_response::<ResumeConversationResponse>(resume_resp)
.expect("deserialize resumeConversation response");
// conversation id should be a valid UUID (simple check)
assert!(!conversation_id.to_string().is_empty());
}
fn create_fake_rollout(code_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) {
let uuid = Uuid::new_v4();
// sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss)
let year = &filename_ts[0..4];
let month = &filename_ts[5..7];
let day = &filename_ts[8..10];
let dir = code_home.join("sessions").join(year).join(month).join(day);
fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("create sessions dir: {e}"));
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
let mut lines = Vec::new();
// Meta line with timestamp
lines.push(json!({"timestamp": meta_rfc3339}).to_string());
// Minimal user message entry as a persisted response item
lines.push(
json!({
"type":"message",
"role":"user",
"content":[{"type":"input_text","text": preview}]
})
.to_string(),
);
fs::write(file_path, lines.join("\n") + "\n")
.unwrap_or_else(|e| panic!("write rollout file: {e}"));
}
+144
View File
@@ -0,0 +1,144 @@
use std::path::Path;
use std::time::Duration;
use code_core::auth::login_with_api_key;
use code_app_server_protocol::CancelLoginChatGptParams;
use code_app_server_protocol::CancelLoginChatGptResponse;
use code_app_server_protocol::GetAuthStatusParams;
use code_app_server_protocol::GetAuthStatusResponse;
use code_app_server_protocol::LoginChatGptResponse;
use code_app_server_protocol::LogoutChatGptResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Helper to create a config.toml; mirrors create_conversation.rs
fn create_config_toml(code_home: &Path) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(
config_toml,
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "http://127.0.0.1:0/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#,
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn logout_chatgpt_removes_auth() {
let code_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(code_home.path()).expect("write config.toml");
login_with_api_key(code_home.path(), "sk-test-key").expect("seed api key");
assert!(code_home.path().join("auth.json").exists());
let mut mcp = McpProcess::new_with_env(code_home.path(), &[("OPENAI_API_KEY", None)])
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let id = mcp
.send_logout_chat_gpt_request()
.await
.expect("send logoutChatGpt");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(id)),
)
.await
.expect("logoutChatGpt timeout")
.expect("logoutChatGpt response");
let _ok: LogoutChatGptResponse = to_response(resp).expect("deserialize logout response");
assert!(
!code_home.path().join("auth.json").exists(),
"auth.json should be deleted"
);
// Verify status reflects signed-out state.
let status_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await
.expect("send getAuthStatus");
let status_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(status_id)),
)
.await
.expect("getAuthStatus timeout")
.expect("getAuthStatus response");
let status: GetAuthStatusResponse = to_response(status_resp).expect("deserialize status");
assert_eq!(status.auth_method, None);
assert_eq!(status.auth_token, None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn login_and_cancel_chatgpt() {
let code_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(code_home.path()).expect("write config.toml");
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let login_id = mcp
.send_login_chat_gpt_request()
.await
.expect("send loginChatGpt");
let login_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(login_id)),
)
.await
.expect("loginChatGpt timeout")
.expect("loginChatGpt response");
let login: LoginChatGptResponse = to_response(login_resp).expect("deserialize login resp");
let cancel_id = mcp
.send_cancel_login_chat_gpt_request(CancelLoginChatGptParams {
login_id: login.login_id,
})
.await
.expect("send cancelLoginChatGpt");
let cancel_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),
)
.await
.expect("cancelLoginChatGpt timeout")
.expect("cancelLoginChatGpt response");
let _ok: CancelLoginChatGptResponse =
to_response(cancel_resp).expect("deserialize cancel response");
// Optionally observe the completion notification; do not fail if it races.
let maybe_note = timeout(
Duration::from_secs(2),
mcp.read_stream_until_notification_message("codex/event/login_chat_gpt_complete"),
)
.await;
if maybe_note.is_err() {
eprintln!("warning: did not observe login_chat_gpt_complete notification after cancel");
}
}
+13
View File
@@ -0,0 +1,13 @@
mod archive_conversation;
mod auth;
mod code_message_processor_flow;
mod config;
mod create_conversation;
mod fuzzy_file_search;
mod interrupt;
mod list_resume;
mod login;
mod send_message;
mod set_default_model;
mod user_agent;
mod user_info;
@@ -0,0 +1,186 @@
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::to_response;
use code_app_server_protocol::AddConversationListenerParams;
use code_app_server_protocol::AddConversationSubscriptionResponse;
use code_app_server_protocol::InputItem;
use code_app_server_protocol::JSONRPCNotification;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::NewConversationParams;
use code_app_server_protocol::NewConversationResponse;
use code_app_server_protocol::RequestId;
use code_app_server_protocol::SendUserMessageParams;
use code_app_server_protocol::SendUserMessageResponse;
use code_protocol::ConversationId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn test_send_message_success() {
// Spin up a mock completions server that immediately ends the Codex turn.
// Two Codex turns hit the mock model (session start + send-user-message). Provide two SSE responses.
let responses = vec![
create_final_assistant_message_sse_response("Done").expect("build mock assistant message"),
create_final_assistant_message_sse_response("Done").expect("build mock assistant message"),
];
let server = create_mock_chat_completions_server(responses).await;
// Create a temporary Codex home with config pointing at the mock server.
let code_home = TempDir::new().expect("create temp dir");
create_config_toml(code_home.path(), &server.uri()).expect("write config.toml");
// Start MCP server process and initialize.
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timed out")
.expect("init failed");
// Start a conversation using the new wire API.
let new_conv_id = mcp
.send_new_conversation_request(NewConversationParams::default())
.await
.expect("send newConversation");
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await
.expect("newConversation timeout")
.expect("newConversation resp");
let NewConversationResponse {
conversation_id, ..
} = to_response::<_>(new_conv_resp).expect("deserialize newConversation response");
// 2) addConversationListener
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
let add_listener_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
)
.await
.expect("addConversationListener timeout")
.expect("addConversationListener resp");
let AddConversationSubscriptionResponse { subscription_id: _ } =
to_response::<_>(add_listener_resp).expect("deserialize addConversationListener response");
// Now exercise sendUserMessage twice.
send_message("Hello", conversation_id, &mut mcp).await;
send_message("Hello again", conversation_id, &mut mcp).await;
}
#[expect(clippy::expect_used)]
async fn send_message(message: &str, conversation_id: ConversationId, mcp: &mut McpProcess) {
// Now exercise sendUserMessage.
let send_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![InputItem::Text {
text: message.to_string(),
}],
})
.await
.expect("send sendUserMessage");
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_id)),
)
.await
.expect("sendUserMessage response timeout")
.expect("sendUserMessage response error");
let _ok: SendUserMessageResponse = to_response::<SendUserMessageResponse>(response)
.expect("deserialize sendUserMessage response");
// Verify the task_finished notification is received.
// Note this also ensures that the final request to the server was made.
let task_finished_notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await
.expect("task_finished_notification timeout")
.expect("task_finished_notification resp");
let serde_json::Value::Object(map) = task_finished_notification
.params
.expect("notification should have params")
else {
panic!("task_finished_notification should have params");
};
assert_eq!(
map.get("conversationId")
.expect("should have conversationId"),
&serde_json::Value::String(conversation_id.to_string())
);
}
#[tokio::test]
async fn test_send_message_session_not_found() {
// Start MCP without creating a Codex session
let code_home = TempDir::new().expect("tempdir");
let mut mcp = McpProcess::new(code_home.path()).await.expect("spawn");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("timeout")
.expect("init");
let unknown = ConversationId::new();
let req_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id: unknown,
items: vec![InputItem::Text {
text: "ping".to_string(),
}],
})
.await
.expect("send sendUserMessage");
// Expect an error response for unknown conversation.
let err = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(req_id)),
)
.await
.expect("timeout")
.expect("error");
assert_eq!(err.id, RequestId::Integer(req_id));
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn create_config_toml(code_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
@@ -0,0 +1,76 @@
use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::RequestId;
use code_app_server_protocol::SetDefaultModelParams;
use code_app_server_protocol::SetDefaultModelResponse;
use code_core::config::ConfigToml;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn set_default_model_persists_overrides() {
let code_home = TempDir::new().expect("create tempdir");
create_config_toml(code_home.path()).expect("write config.toml");
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let params = SetDefaultModelParams {
model: Some("gpt-4.1".to_string()),
reasoning_effort: None,
};
let request_id = mcp
.send_set_default_model_request(params)
.await
.expect("send setDefaultModel");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("setDefaultModel timeout")
.expect("setDefaultModel response");
let _: SetDefaultModelResponse =
to_response(resp).expect("deserialize setDefaultModel response");
let config_path = code_home.path().join("config.toml");
let config_contents = tokio::fs::read_to_string(&config_path)
.await
.expect("read config.toml");
let config_toml: ConfigToml = toml::from_str(&config_contents).expect("parse config.toml");
assert_eq!(
ConfigToml {
model: Some("gpt-4.1".to_string()),
model_reasoning_effort: None,
..Default::default()
},
config_toml,
);
}
// Helper to create a config.toml; mirrors create_conversation.rs
fn create_config_toml(code_home: &Path) -> std::io::Result<()> {
let config_toml = code_home.join("config.toml");
std::fs::write(
config_toml,
r#"
model = "gpt-5-codex"
model_reasoning_effort = "medium"
"#,
)
}
@@ -0,0 +1,50 @@
use app_test_support::McpProcess;
use app_test_support::to_response;
use code_app_server_protocol::GetUserAgentResponse;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_user_agent_returns_current_code_user_agent() {
let code_home = TempDir::new().unwrap_or_else(|err| panic!("create tempdir: {err}"));
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("initialize timeout")
.expect("initialize request");
let request_id = mcp
.send_get_user_agent_request()
.await
.expect("send getUserAgent");
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getUserAgent timeout")
.expect("getUserAgent response");
let os_info = os_info::get();
let user_agent = format!(
"code_cli_rs/0.0.0 ({} {}; {}) {} (codex-app-server-tests; 0.1.0)",
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
code_core::terminal::user_agent()
);
let received: GetUserAgentResponse =
to_response(response).expect("deserialize getUserAgent response");
let expected = GetUserAgentResponse { user_agent };
assert_eq!(received, expected);
}
@@ -0,0 +1,78 @@
use std::time::Duration;
use anyhow::Context;
use app_test_support::McpProcess;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use code_app_server_protocol::JSONRPCResponse;
use code_app_server_protocol::RequestId;
use code_app_server_protocol::UserInfoResponse;
use code_core::auth::AuthDotJson;
use code_core::auth::get_auth_file;
use code_core::auth::write_auth_json;
use code_core::token_data::IdTokenInfo;
use code_core::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_info_returns_email_from_auth_json() {
let code_home = TempDir::new().expect("create tempdir");
let auth_path = get_auth_file(code_home.path());
let mut id_token = IdTokenInfo::default();
id_token.email = Some("[email protected]".to_string());
id_token.raw_jwt = encode_id_token_with_email("[email protected]").expect("encode id token");
let auth = AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token,
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
account_id: None,
}),
last_refresh: None,
};
write_auth_json(&auth_path, &auth).expect("write auth.json");
let mut mcp = McpProcess::new(code_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("initialize timeout")
.expect("initialize request");
let request_id = mcp.send_user_info_request().await.expect("send userInfo");
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("userInfo timeout")
.expect("userInfo response");
let received: UserInfoResponse = to_response(response).expect("deserialize userInfo response");
let expected = UserInfoResponse {
alleged_user_email: Some("[email protected]".to_string()),
};
assert_eq!(received, expected);
}
fn encode_id_token_with_email(email: &str) -> anyhow::Result<String> {
let header_b64 = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&json!({ "alg": "none", "typ": "JWT" }))
.context("serialize jwt header")?,
);
let payload =
serde_json::to_vec(&json!({ "email": email })).context("serialize jwt payload")?;
let payload_b64 = URL_SAFE_NO_PAD.encode(payload);
Ok(format!("{header_b64}.{payload_b64}.signature"))
}
+27
View File
@@ -0,0 +1,27 @@
[package]
edition = "2024"
name = "code-apply-patch"
version = { workspace = true }
[lib]
name = "code_apply_patch"
path = "src/lib.rs"
[[bin]]
name = "apply_patch"
path = "src/main.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
similar = { workspace = true }
thiserror = { workspace = true }
tree-sitter = { workspace = true }
tree-sitter-bash = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
@@ -0,0 +1,75 @@
## `apply_patch`
Use the `apply_patch` shell command to edit files.
Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
May be immediately followed by *** Move to: <new path> if you want to rename the file.
Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
Within a hunk each line starts with:
For instructions on [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines.
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
@@ class BaseClass
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
@@ class BaseClass
@@ def method():
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
The full grammar definition is below:
Patch := Begin { FileOp } End
Begin := "*** Begin Patch" NEWLINE
End := "*** End Patch" NEWLINE
FileOp := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
DeleteFile := "*** Delete File: " path NEWLINE
UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
MoveTo := "*** Move to: " newPath NEWLINE
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
HunkLine := (" " | "-" | "+") text NEWLINE
A full patch can combine several operations:
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file
- File references can only be relative, NEVER ABSOLUTE.
You can invoke apply_patch like:
```
shell {"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
```
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
pub fn main() -> ! {
code_apply_patch::main()
}
+741
View File
@@ -0,0 +1,741 @@
//! This module is responsible for parsing & validating a patch into a list of "hunks".
//! (It does not attempt to actually check that the patch can be applied to the filesystem.)
//!
//! The official Lark grammar for the apply-patch format is:
//!
//! start: begin_patch hunk+ end_patch
//! begin_patch: "*** Begin Patch" LF
//! end_patch: "*** End Patch" LF?
//!
//! hunk: add_hunk | delete_hunk | update_hunk
//! add_hunk: "*** Add File: " filename LF add_line+
//! delete_hunk: "*** Delete File: " filename LF
//! update_hunk: "*** Update File: " filename LF change_move? change?
//! filename: /(.+)/
//! add_line: "+" /(.+)/ LF -> line
//!
//! change_move: "*** Move to: " filename LF
//! change: (change_context | change_line)+ eof_line?
//! change_context: ("@@" | "@@ " /(.+)/) LF
//! change_line: ("+" | "-" | " ") /(.+)/ LF
//! eof_line: "*** End of File" LF
//!
//! The parser below is a little more lenient than the explicit spec and allows for
//! leading/trailing whitespace around patch markers.
use crate::ApplyPatchArgs;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
const END_PATCH_MARKER: &str = "*** End Patch";
const ADD_FILE_MARKER: &str = "*** Add File: ";
const DELETE_FILE_MARKER: &str = "*** Delete File: ";
const UPDATE_FILE_MARKER: &str = "*** Update File: ";
const MOVE_TO_MARKER: &str = "*** Move to: ";
const EOF_MARKER: &str = "*** End of File";
const CHANGE_CONTEXT_MARKER: &str = "@@ ";
const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
/// Currently, the only OpenAI model that knowingly requires lenient parsing is
/// gpt-4.1. While we could try to require everyone to pass in a strictness
/// param when invoking apply_patch, it is a pain to thread it through all of
/// the call sites, so we resign ourselves allowing lenient parsing for all
/// models. See [`ParseMode::Lenient`] for details on the exceptions we make for
/// gpt-4.1.
const PARSE_IN_STRICT_MODE: bool = false;
#[derive(Debug, PartialEq, Error, Clone)]
pub enum ParseError {
#[error("invalid patch: {0}")]
InvalidPatchError(String),
#[error("invalid hunk at line {line_number}, {message}")]
InvalidHunkError { message: String, line_number: usize },
}
use ParseError::*;
#[derive(Debug, PartialEq, Clone)]
#[allow(clippy::enum_variant_names)]
pub enum Hunk {
AddFile {
path: PathBuf,
contents: String,
},
DeleteFile {
path: PathBuf,
},
UpdateFile {
path: PathBuf,
move_path: Option<PathBuf>,
/// Chunks should be in order, i.e. the `change_context` of one chunk
/// should occur later in the file than the previous chunk.
chunks: Vec<UpdateFileChunk>,
},
}
impl Hunk {
pub fn resolve_path(&self, cwd: &Path) -> PathBuf {
match self {
Hunk::AddFile { path, .. } => cwd.join(path),
Hunk::DeleteFile { path } => cwd.join(path),
Hunk::UpdateFile { path, .. } => cwd.join(path),
}
}
}
use Hunk::*;
#[derive(Debug, PartialEq, Clone)]
pub struct UpdateFileChunk {
/// A single line of context used to narrow down the position of the chunk
/// (this is usually a class, method, or function definition.)
pub change_context: Option<String>,
/// A contiguous block of lines that should be replaced with `new_lines`.
/// `old_lines` must occur strictly after `change_context`.
pub old_lines: Vec<String>,
pub new_lines: Vec<String>,
/// If set to true, `old_lines` must occur at the end of the source file.
/// (Tolerance around trailing newlines should be encouraged.)
pub is_end_of_file: bool,
}
pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
let mode = if PARSE_IN_STRICT_MODE {
ParseMode::Strict
} else {
ParseMode::Lenient
};
parse_patch_text(patch, mode)
}
enum ParseMode {
/// Parse the patch text argument as is.
Strict,
/// GPT-4.1 is known to formulate the `command` array for the `local_shell`
/// tool call for `apply_patch` call using something like the following:
///
/// ```json
/// [
/// "apply_patch",
/// "<<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
/// ]
/// ```
///
/// This is a problem because `local_shell` is a bit of a misnomer: the
/// `command` is not invoked by passing the arguments to a shell like Bash,
/// but are invoked using something akin to `execvpe(3)`.
///
/// This is significant in this case because where a shell would interpret
/// `<<'EOF'...` as a heredoc and pass the contents via stdin (which is
/// fine, as `apply_patch` is specified to read from stdin if no argument is
/// passed), `execvpe(3)` interprets the heredoc as a literal string. To get
/// the `local_shell` tool to run a command the way shell would, the
/// `command` array must be something like:
///
/// ```json
/// [
/// "bash",
/// "-lc",
/// "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",
/// ]
/// ```
///
/// In lenient mode, we check if the argument to `apply_patch` starts with
/// `<<'EOF'` and ends with `EOF\n`. If so, we strip off these markers,
/// trim() the result, and treat what is left as the patch text.
Lenient,
}
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
let lines: Vec<&str> = patch.trim().lines().collect();
let lines: &[&str] = match check_patch_boundaries_strict(&lines) {
Ok(()) => &lines,
Err(e) => match mode {
ParseMode::Strict => {
return Err(e);
}
ParseMode::Lenient => check_patch_boundaries_lenient(&lines, e)?,
},
};
let mut hunks: Vec<Hunk> = Vec::new();
// The above checks ensure that lines.len() >= 2.
let last_line_index = lines.len().saturating_sub(1);
let mut remaining_lines = &lines[1..last_line_index];
let mut line_number = 2;
while !remaining_lines.is_empty() {
let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number)?;
hunks.push(hunk);
line_number += hunk_lines;
remaining_lines = &remaining_lines[hunk_lines..]
}
let patch = lines.join("\n");
Ok(ApplyPatchArgs {
hunks,
patch,
workdir: None,
})
}
/// Checks the start and end lines of the patch text for `apply_patch`,
/// returning an error if they do not match the expected markers.
fn check_patch_boundaries_strict(lines: &[&str]) -> Result<(), ParseError> {
let (first_line, last_line) = match lines {
[] => (None, None),
[first] => (Some(first), Some(first)),
[first, .., last] => (Some(first), Some(last)),
};
check_start_and_end_lines_strict(first_line, last_line)
}
/// If we are in lenient mode, we check if the first line starts with `<<EOF`
/// (possibly quoted) and the last line ends with `EOF`. There must be at least
/// 4 lines total because the heredoc markers take up 2 lines and the patch text
/// must have at least 2 lines.
///
/// If successful, returns the lines of the patch text that contain the patch
/// contents, excluding the heredoc markers.
fn check_patch_boundaries_lenient<'a>(
original_lines: &'a [&'a str],
original_parse_error: ParseError,
) -> Result<&'a [&'a str], ParseError> {
match original_lines {
[first, .., last] => {
if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"")
&& last.ends_with("EOF")
&& original_lines.len() >= 4
{
let inner_lines = &original_lines[1..original_lines.len() - 1];
match check_patch_boundaries_strict(inner_lines) {
Ok(()) => Ok(inner_lines),
Err(e) => Err(e),
}
} else {
Err(original_parse_error)
}
}
_ => Err(original_parse_error),
}
}
fn check_start_and_end_lines_strict(
first_line: Option<&&str>,
last_line: Option<&&str>,
) -> Result<(), ParseError> {
match (first_line, last_line) {
(Some(&first), Some(&last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => {
Ok(())
}
(Some(&first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from(
"The first line of the patch must be '*** Begin Patch'",
))),
_ => Err(InvalidPatchError(String::from(
"The last line of the patch must be '*** End Patch'",
))),
}
}
/// Attempts to parse a single hunk from the start of lines.
/// Returns the parsed hunk and the number of lines parsed (or a ParseError).
fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), ParseError> {
// Be tolerant of case mismatches and extra padding around marker strings.
let first_line = lines[0].trim();
if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
// Add File
let mut contents = String::new();
let mut parsed_lines = 1;
for add_line in &lines[1..] {
if let Some(line_to_add) = add_line.strip_prefix('+') {
contents.push_str(line_to_add);
contents.push('\n');
parsed_lines += 1;
} else {
break;
}
}
return Ok((
AddFile {
path: PathBuf::from(path),
contents,
},
parsed_lines,
));
} else if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) {
// Delete File
return Ok((
DeleteFile {
path: PathBuf::from(path),
},
1,
));
} else if let Some(path) = first_line.strip_prefix(UPDATE_FILE_MARKER) {
// Update File
let mut remaining_lines = &lines[1..];
let mut parsed_lines = 1;
// Optional: move file line
let move_path = remaining_lines
.first()
.and_then(|x| x.strip_prefix(MOVE_TO_MARKER));
if move_path.is_some() {
remaining_lines = &remaining_lines[1..];
parsed_lines += 1;
}
let mut chunks = Vec::new();
// NOTE: we need to know to stop once we reach the next special marker header.
while !remaining_lines.is_empty() {
// Skip over any completely blank lines that may separate chunks.
if remaining_lines[0].trim().is_empty() {
parsed_lines += 1;
remaining_lines = &remaining_lines[1..];
continue;
}
if remaining_lines[0].starts_with("***") {
break;
}
let (chunk, chunk_lines) = parse_update_file_chunk(
remaining_lines,
line_number + parsed_lines,
chunks.is_empty(),
)?;
chunks.push(chunk);
parsed_lines += chunk_lines;
remaining_lines = &remaining_lines[chunk_lines..]
}
if chunks.is_empty() {
return Err(InvalidHunkError {
message: format!("Update file hunk for path '{path}' is empty"),
line_number,
});
}
return Ok((
UpdateFile {
path: PathBuf::from(path),
move_path: move_path.map(PathBuf::from),
chunks,
},
parsed_lines,
));
}
Err(InvalidHunkError {
message: format!(
"'{first_line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
),
line_number,
})
}
fn parse_update_file_chunk(
lines: &[&str],
line_number: usize,
allow_missing_context: bool,
) -> Result<(UpdateFileChunk, usize), ParseError> {
if lines.is_empty() {
return Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
line_number,
});
}
// If we see an explicit context marker @@ or @@ <context>, consume it; otherwise, optionally
// allow treating the chunk as starting directly with diff lines.
let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
(None, 1)
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
(Some(context.to_string()), 1)
} else {
if !allow_missing_context {
return Err(InvalidHunkError {
message: format!(
"Expected update hunk to start with a @@ context marker, got: '{}'",
lines[0]
),
line_number,
});
}
(None, 0)
};
if start_index >= lines.len() {
return Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
line_number: line_number + 1,
});
}
let mut chunk = UpdateFileChunk {
change_context,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
};
let mut parsed_lines = 0;
for line in &lines[start_index..] {
match *line {
EOF_MARKER => {
if parsed_lines == 0 {
return Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
line_number: line_number + 1,
});
}
chunk.is_end_of_file = true;
parsed_lines += 1;
break;
}
line_contents => {
match line_contents.chars().next() {
None => {
// Interpret this as an empty line.
chunk.old_lines.push(String::new());
chunk.new_lines.push(String::new());
}
Some(' ') => {
chunk.old_lines.push(line_contents[1..].to_string());
chunk.new_lines.push(line_contents[1..].to_string());
}
Some('+') => {
chunk.new_lines.push(line_contents[1..].to_string());
}
Some('-') => {
chunk.old_lines.push(line_contents[1..].to_string());
}
_ => {
if parsed_lines == 0 {
return Err(InvalidHunkError {
message: format!(
"Unexpected line found in update hunk: '{line_contents}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
),
line_number: line_number + 1,
});
}
// Assume this is the start of the next hunk.
break;
}
}
parsed_lines += 1;
}
}
}
Ok((chunk, parsed_lines + start_index))
}
#[test]
fn test_parse_patch() {
assert_eq!(
parse_patch_text("bad", ParseMode::Strict),
Err(InvalidPatchError(
"The first line of the patch must be '*** Begin Patch'".to_string()
))
);
assert_eq!(
parse_patch_text("*** Begin Patch\nbad", ParseMode::Strict),
Err(InvalidPatchError(
"The last line of the patch must be '*** End Patch'".to_string()
))
);
assert_eq!(
parse_patch_text(
"*** Begin Patch\n\
*** Update File: test.py\n\
*** End Patch",
ParseMode::Strict
),
Err(InvalidHunkError {
message: "Update file hunk for path 'test.py' is empty".to_string(),
line_number: 2,
})
);
assert_eq!(
parse_patch_text(
"*** Begin Patch\n\
*** End Patch",
ParseMode::Strict
)
.unwrap()
.hunks,
Vec::new()
);
assert_eq!(
parse_patch_text(
"*** Begin Patch\n\
*** Add File: path/add.py\n\
+abc\n\
+def\n\
*** Delete File: path/delete.py\n\
*** Update File: path/update.py\n\
*** Move to: path/update2.py\n\
@@ def f():\n\
- pass\n\
+ return 123\n\
*** End Patch",
ParseMode::Strict
)
.unwrap()
.hunks,
vec![
AddFile {
path: PathBuf::from("path/add.py"),
contents: "abc\ndef\n".to_string()
},
DeleteFile {
path: PathBuf::from("path/delete.py")
},
UpdateFile {
path: PathBuf::from("path/update.py"),
move_path: Some(PathBuf::from("path/update2.py")),
chunks: vec![UpdateFileChunk {
change_context: Some("def f():".to_string()),
old_lines: vec![" pass".to_string()],
new_lines: vec![" return 123".to_string()],
is_end_of_file: false
}]
}
]
);
// Update hunk followed by another hunk (Add File).
assert_eq!(
parse_patch_text(
"*** Begin Patch\n\
*** Update File: file.py\n\
@@\n\
+line\n\
*** Add File: other.py\n\
+content\n\
*** End Patch",
ParseMode::Strict
)
.unwrap()
.hunks,
vec![
UpdateFile {
path: PathBuf::from("file.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
change_context: None,
old_lines: vec![],
new_lines: vec!["line".to_string()],
is_end_of_file: false
}],
},
AddFile {
path: PathBuf::from("other.py"),
contents: "content\n".to_string()
}
]
);
// Update hunk without an explicit @@ header for the first chunk should parse.
// Use a raw string to preserve the leading space diff marker on the context line.
assert_eq!(
parse_patch_text(
r#"*** Begin Patch
*** Update File: file2.py
import foo
+bar
*** End Patch"#,
ParseMode::Strict
)
.unwrap()
.hunks,
vec![UpdateFile {
path: PathBuf::from("file2.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
change_context: None,
old_lines: vec!["import foo".to_string()],
new_lines: vec!["import foo".to_string(), "bar".to_string()],
is_end_of_file: false,
}],
}]
);
}
#[test]
fn test_parse_patch_lenient() {
let patch_text = r#"*** Begin Patch
*** Update File: file2.py
import foo
+bar
*** End Patch"#;
let expected_patch = vec![UpdateFile {
path: PathBuf::from("file2.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
change_context: None,
old_lines: vec!["import foo".to_string()],
new_lines: vec!["import foo".to_string(), "bar".to_string()],
is_end_of_file: false,
}],
}];
let expected_error =
InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
let patch_text_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
assert_eq!(
parse_patch_text(&patch_text_in_heredoc, ParseMode::Strict),
Err(expected_error.clone())
);
assert_eq!(
parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient),
Ok(ApplyPatchArgs {
hunks: expected_patch.clone(),
patch: patch_text.to_string(),
workdir: None,
})
);
let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
assert_eq!(
parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Strict),
Err(expected_error.clone())
);
assert_eq!(
parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient),
Ok(ApplyPatchArgs {
hunks: expected_patch.clone(),
patch: patch_text.to_string(),
workdir: None,
})
);
let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
assert_eq!(
parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Strict),
Err(expected_error.clone())
);
assert_eq!(
parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient),
Ok(ApplyPatchArgs {
hunks: expected_patch,
patch: patch_text.to_string(),
workdir: None,
})
);
let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n");
assert_eq!(
parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Strict),
Err(expected_error.clone())
);
assert_eq!(
parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Lenient),
Err(expected_error.clone())
);
let patch_text_with_missing_closing_heredoc =
"<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
assert_eq!(
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict),
Err(expected_error)
);
assert_eq!(
parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient),
Err(InvalidPatchError(
"The last line of the patch must be '*** End Patch'".to_string()
))
);
}
#[test]
fn test_parse_one_hunk() {
assert_eq!(
parse_one_hunk(&["bad"], 234),
Err(InvalidHunkError {
message: "'bad' is not a valid hunk header. \
Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'".to_string(),
line_number: 234
})
);
// Other edge cases are already covered by tests above/below.
}
#[test]
fn test_update_file_chunk() {
assert_eq!(
parse_update_file_chunk(&["bad"], 123, false),
Err(InvalidHunkError {
message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
.to_string(),
line_number: 123
})
);
assert_eq!(
parse_update_file_chunk(&["@@"], 123, false),
Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
line_number: 124
})
);
assert_eq!(
parse_update_file_chunk(&["@@", "bad"], 123, false),
Err(InvalidHunkError {
message: "Unexpected line found in update hunk: 'bad'. \
Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)".to_string(),
line_number: 124
})
);
assert_eq!(
parse_update_file_chunk(&["@@", "*** End of File"], 123, false),
Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
line_number: 124
})
);
assert_eq!(
parse_update_file_chunk(
&[
"@@ change_context",
"",
" context",
"-remove",
"+add",
" context2",
"*** End Patch",
],
123,
false
),
Ok((
(UpdateFileChunk {
change_context: Some("change_context".to_string()),
old_lines: vec![
"".to_string(),
"context".to_string(),
"remove".to_string(),
"context2".to_string()
],
new_lines: vec![
"".to_string(),
"context".to_string(),
"add".to_string(),
"context2".to_string()
],
is_end_of_file: false
}),
6
))
);
assert_eq!(
parse_update_file_chunk(&["@@", "+line", "*** End of File"], 123, false),
Ok((
(UpdateFileChunk {
change_context: None,
old_lines: vec![],
new_lines: vec!["line".to_string()],
is_end_of_file: true
}),
3
))
);
}
+151
View File
@@ -0,0 +1,151 @@
/// Attempt to find the sequence of `pattern` lines within `lines` beginning at or after `start`.
/// Returns the starting index of the match or `None` if not found. Matches are attempted with
/// decreasing strictness: exact match, then ignoring trailing whitespace, then ignoring leading
/// and trailing whitespace. When `eof` is true, we first try starting at the end-of-file (so that
/// patterns intended to match file endings are applied at the end), and fall back to searching
/// from `start` if needed.
///
/// Special cases handled defensively:
/// • Empty `pattern` → returns `Some(start)` (no-op match)
/// • `pattern.len() > lines.len()` → returns `None` (cannot match, avoids
/// out‑of‑bounds panic that occurred pre‑2025‑04‑12)
pub(crate) fn seek_sequence(
lines: &[String],
pattern: &[String],
start: usize,
eof: bool,
) -> Option<usize> {
if pattern.is_empty() {
return Some(start);
}
// When the pattern is longer than the available input there is no possible
// match. Early‑return to avoid the out‑of‑bounds slice that would occur in
// the search loops below (previously caused a panic when
// `pattern.len() > lines.len()`).
if pattern.len() > lines.len() {
return None;
}
let search_start = if eof && lines.len() >= pattern.len() {
lines.len() - pattern.len()
} else {
start
};
// Exact match first.
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
if lines[i..i + pattern.len()] == *pattern {
return Some(i);
}
}
// Then rstrip match.
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if lines[i + p_idx].trim_end() != pat.trim_end() {
ok = false;
break;
}
}
if ok {
return Some(i);
}
}
// Finally, trim both sides to allow more lenience.
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if lines[i + p_idx].trim() != pat.trim() {
ok = false;
break;
}
}
if ok {
return Some(i);
}
}
// ------------------------------------------------------------------
// Final, most permissive pass – attempt to match after *normalising*
// common Unicode punctuation to their ASCII equivalents so that diffs
// authored with plain ASCII characters can still be applied to source
// files that contain typographic dashes / quotes, etc. This mirrors the
// fuzzy behaviour of `git apply` which ignores minor byte-level
// differences when locating context lines.
// ------------------------------------------------------------------
fn normalise(s: &str) -> String {
s.trim()
.chars()
.map(|c| match c {
// Various dash / hyphen code-points → ASCII '-'
'\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
| '\u{2212}' => '-',
// Fancy single quotes → '\''
'\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
// Fancy double quotes → '"'
'\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
// Non-breaking space and other odd spaces → normal space
'\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
| '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
| '\u{3000}' => ' ',
other => other,
})
.collect::<String>()
}
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if normalise(&lines[i + p_idx]) != normalise(pat) {
ok = false;
break;
}
}
if ok {
return Some(i);
}
}
None
}
#[cfg(test)]
mod tests {
use super::seek_sequence;
use std::string::ToString;
fn to_vec(strings: &[&str]) -> Vec<String> {
strings.iter().map(ToString::to_string).collect()
}
#[test]
fn test_exact_match_finds_sequence() {
let lines = to_vec(&["foo", "bar", "baz"]);
let pattern = to_vec(&["bar", "baz"]);
assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(1));
}
#[test]
fn test_rstrip_match_ignores_trailing_whitespace() {
let lines = to_vec(&["foo ", "bar\t\t"]);
// Pattern omits trailing whitespace.
let pattern = to_vec(&["foo", "bar"]);
assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(0));
}
#[test]
fn test_trim_match_ignores_leading_and_trailing_whitespace() {
let lines = to_vec(&[" foo ", " bar\t"]);
// Pattern omits any additional whitespace.
let pattern = to_vec(&["foo", "bar"]);
assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(0));
}
#[test]
fn test_pattern_longer_than_input_returns_none() {
let lines = to_vec(&["just one line"]);
let pattern = to_vec(&["too", "many", "lines"]);
// Should not panic – must return None when pattern cannot possibly fit.
assert_eq!(seek_sequence(&lines, &pattern, 0, false), None);
}
}
@@ -0,0 +1,59 @@
use std::io::Read;
use std::io::Write;
pub fn main() -> ! {
let exit_code = run_main();
std::process::exit(exit_code);
}
/// We would prefer to return `std::process::ExitCode`, but its `exit_process()`
/// method is still a nightly API and we want main() to return !.
pub fn run_main() -> i32 {
// Expect either one argument (the full apply_patch payload) or read it from stdin.
let mut args = std::env::args_os();
let _argv0 = args.next();
let patch_arg = match args.next() {
Some(arg) => match arg.into_string() {
Ok(s) => s,
Err(_) => {
eprintln!("Error: apply_patch requires a UTF-8 PATCH argument.");
return 1;
}
},
None => {
// No argument provided; attempt to read the patch from stdin.
let mut buf = String::new();
match std::io::stdin().read_to_string(&mut buf) {
Ok(_) => {
if buf.is_empty() {
eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply-patch");
return 2;
}
buf
}
Err(err) => {
eprintln!("Error: Failed to read PATCH from stdin.\n{err}");
return 1;
}
}
}
};
// Refuse extra args to avoid ambiguity.
if args.next().is_some() {
eprintln!("Error: apply_patch accepts exactly one argument.");
return 2;
}
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
Ok(()) => {
// Flush to ensure output ordering when used in pipelines.
let _ = stdout.flush();
0
}
Err(_) => 1,
}
}
@@ -0,0 +1,138 @@
use crate::{EmbeddedApplyPatch};
/// Locate an embedded `apply_patch <<EOF ... EOF` heredoc in a shell script and
/// return its patch body, optional `cd` path (when present as `cd <path> &&` on
/// the same line), and the byte range to remove from the script.
///
/// This uses a lightweight textual scan that works reliably for the common
/// forms we emit in tool calls. It intentionally avoids failing on unexpected
/// syntax; if anything is off, we simply return Ok(None).
pub(crate) fn find_embedded_apply_patch(script: &str) -> Result<Option<EmbeddedApplyPatch>, ()> {
let _bytes = script.as_bytes();
let mut i = 0usize;
// Support both command spellings accepted by apply_patch tooling.
// We scan for the next occurrence of either token and treat the first one
// we find (closest to the current cursor) as the candidate.
const CMD1: &str = "apply_patch";
const CMD2: &str = "applypatch";
while i < script.len() {
// Find next match of either token from i
let p1 = script[i..].find(CMD1).map(|p| (p, CMD1.len()));
let p2 = script[i..].find(CMD2).map(|p| (p, CMD2.len()));
let (rel, cmd_len) = match (p1, p2) {
(Some(a), Some(b)) => if a.0 <= b.0 { a } else { b },
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => break,
};
let start = i + rel;
// Ensure token boundary (start or whitespace/punct before, and space or '<' after)
let ok_before = start == 0
|| script[..start]
.chars()
.rev()
.next()
.map(|c| c.is_whitespace() || ";|&".contains(c))
.unwrap_or(true);
let after = script[start..].chars().skip(cmd_len).next();
let ok_after = after.map(|c| c.is_whitespace() || c == '<').unwrap_or(false);
if !ok_before || !ok_after {
i = start + cmd_len;
continue;
}
// Find heredoc op: << (allow spaces between)
let rest = &script[start + cmd_len..];
let mut j = 0usize;
// Skip spaces
while j < rest.len() && rest.as_bytes()[j].is_ascii_whitespace() { j += 1; }
if j + 1 >= rest.len() || rest[j..].get(..2).unwrap_or("") != "<<" {
i = start + cmd_len;
continue;
}
j += 2; // past <<
// Skip spaces
while j < rest.len() && rest.as_bytes()[j].is_ascii_whitespace() { j += 1; }
if j >= rest.len() { break; }
// Parse delimiter token: EOF, 'EOF', or "EOF"
let (delim, after_delim_idx) = match rest.as_bytes()[j] {
b'\'' => {
// 'EOF'
let k = rest[j+1..].find('\'').map(|p| j + 1 + p);
if let Some(endq) = k { (&rest[j+1..endq], endq + 1) } else { i = start + 2; continue; }
}
b'"' => {
let k = rest[j+1..].find('"').map(|p| j + 1 + p);
if let Some(endq) = k { (&rest[j+1..endq], endq + 1) } else { i = start + 2; continue; }
}
_ => {
// Bare word up to whitespace
let mut k = j;
while k < rest.len() && !rest.as_bytes()[k].is_ascii_whitespace() { k += 1; }
(&rest[j..k], k)
}
};
let delim = delim.trim();
if delim.is_empty() { i = start + 2; continue; }
// Find end of header line (newline)
let header_slice = &rest[after_delim_idx..];
let Some(nl_rel) = header_slice.find('\n') else { break };
let header_end = start + cmd_len + after_delim_idx + nl_rel + 1; // pos after newline
// Search for terminator line equal to delim
let mut scan = header_end;
let mut found_end: Option<usize> = None;
while scan < script.len() {
let _line_start = scan;
if let Some(nl) = script[scan..].find('\n') {
let line = &script[scan..scan+nl];
if line == delim { found_end = Some(scan + nl + 1); break; }
scan += nl + 1;
} else {
// Last line without newline
let line = &script[scan..];
if line == delim { found_end = Some(script.len()); }
break;
}
}
let Some(body_end) = found_end else { i = start + 2; continue; };
// Determine line start for this statement and optional preceding `cd <path> &&`
let line_start = script[..start].rfind('\n').map(|p| p + 1).unwrap_or(0);
let before_apply = &script[line_start..start];
let mut cd_path: Option<String> = None;
let mut stmt_begin = start;
{
let prefix = before_apply.trim_end();
// Try to match "cd <path> &&" directly before apply_patch (allow whitespace)
if let Some(and_and_pos) = prefix.rfind("&&") {
let left = prefix[..and_and_pos].trim_end();
// Ensure no other tokens after && besides whitespace
if prefix[and_and_pos+2..].trim().is_empty() {
if let Some(rest) = left.strip_suffix(|c: char| c.is_whitespace()) {
let left_trim = rest.trim_end();
if let Some(arg) = left_trim.strip_prefix("cd ") {
let path = arg.trim();
// Take first token or a single-quoted/quoted string
let path_str = if (path.starts_with('\'') && path.ends_with('\'')) || (path.starts_with('"') && path.ends_with('"')) {
path[1..path.len().saturating_sub(1)].to_string()
} else {
// up to next whitespace
let tok_end = path.find(char::is_whitespace).unwrap_or(path.len());
path[..tok_end].to_string()
};
cd_path = Some(path_str);
// Include the cd... && in the removal range
stmt_begin = line_start + left.find("cd ").map(|p| p).unwrap_or(0);
}
}
}
}
}
let patch_body = script[header_end..body_end].trim_end_matches('\n').to_string();
return Ok(Some(EmbeddedApplyPatch { patch_body, cd_path, stmt_byte_range: (stmt_begin, body_end) }));
}
Ok(None)
}
+3
View File
@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;
+90
View File
@@ -0,0 +1,90 @@
use assert_cmd::prelude::*;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
#[test]
fn test_apply_patch_cli_add_and_update() -> anyhow::Result<()> {
let tmp = tempdir()?;
let file = "cli_test.txt";
let absolute_path = tmp.path().join(file);
// 1) Add a file
let add_patch = format!(
r#"*** Begin Patch
*** Add File: {file}
+hello
*** End Patch"#
);
Command::cargo_bin("apply_patch")
.expect("should find apply_patch binary")
.arg(add_patch)
.current_dir(tmp.path())
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nA {file}\n"));
assert_eq!(fs::read_to_string(&absolute_path)?, "hello\n");
// 2) Update the file
let update_patch = format!(
r#"*** Begin Patch
*** Update File: {file}
@@
-hello
+world
*** End Patch"#
);
Command::cargo_bin("apply_patch")
.expect("should find apply_patch binary")
.arg(update_patch)
.current_dir(tmp.path())
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nM {file}\n"));
assert_eq!(fs::read_to_string(&absolute_path)?, "world\n");
Ok(())
}
#[test]
fn test_apply_patch_cli_stdin_add_and_update() -> anyhow::Result<()> {
let tmp = tempdir()?;
let file = "cli_test_stdin.txt";
let absolute_path = tmp.path().join(file);
// 1) Add a file via stdin
let add_patch = format!(
r#"*** Begin Patch
*** Add File: {file}
+hello
*** End Patch"#
);
let mut cmd =
assert_cmd::Command::cargo_bin("apply_patch").expect("should find apply_patch binary");
cmd.current_dir(tmp.path());
cmd.write_stdin(add_patch)
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nA {file}\n"));
assert_eq!(fs::read_to_string(&absolute_path)?, "hello\n");
// 2) Update the file via stdin
let update_patch = format!(
r#"*** Begin Patch
*** Update File: {file}
@@
-hello
+world
*** End Patch"#
);
let mut cmd =
assert_cmd::Command::cargo_bin("apply_patch").expect("should find apply_patch binary");
cmd.current_dir(tmp.path());
cmd.write_stdin(update_patch)
.assert()
.success()
.stdout(format!("Success. Updated the following files:\nM {file}\n"));
assert_eq!(fs::read_to_string(&absolute_path)?, "world\n");
Ok(())
}
+1
View File
@@ -0,0 +1 @@
mod cli;
+20
View File
@@ -0,0 +1,20 @@
[package]
edition = "2024"
name = "code-arg0"
version = { workspace = true }
[lib]
name = "code_arg0"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
code-apply-patch = { workspace = true }
code-core = { workspace = true }
code-linux-sandbox = { workspace = true }
dotenvy = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
+218
View File
@@ -0,0 +1,218 @@
use std::future::Future;
use std::path::Path;
use std::path::PathBuf;
use code_core::config::resolve_code_path_for_read;
use code_core::CODEX_APPLY_PATCH_ARG1;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::TempDir;
const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox";
const APPLY_PATCH_ARG0: &str = "apply_patch";
const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
/// While we want to deploy the Codex CLI as a single executable for simplicity,
/// we also want to expose some of its functionality as distinct CLIs, so we use
/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows
/// us to simulate deploying multiple executables as a single binary on Mac and
/// Linux (but not Windows).
///
/// When the current executable is invoked through the hard-link or alias named
/// `codex-linux-sandbox` we *directly* execute
/// [`code_linux_sandbox::run_main`] (which never returns). Otherwise we:
///
/// 1. Use [`dotenvy::from_path`] and [`dotenvy::dotenv`] to modify the
/// environment before creating any threads.
/// 2. Construct a Tokio multi-thread runtime.
/// 3. Derive the path to the current executable (so children can re-invoke the
/// sandbox) when running on Linux.
/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any
/// error. Note that `main_fn` receives `code_linux_sandbox_exe:
/// Option<PathBuf>`, as an argument, which is generally needed as part of
/// constructing [`code_core::config::Config`].
///
/// This function should be used to wrap any `main()` function in binary crates
/// in this workspace that depends on these helper CLIs.
pub fn arg0_dispatch_or_else<F, Fut>(main_fn: F) -> anyhow::Result<()>
where
F: FnOnce(Option<PathBuf>) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
// Determine if we were invoked via the special alias.
let mut args = std::env::args_os();
let argv0 = args.next().unwrap_or_default();
let exe_name = Path::new(&argv0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if exe_name == LINUX_SANDBOX_ARG0 {
// Safety: [`run_main`] never returns.
code_linux_sandbox::run_main();
} else if exe_name == APPLY_PATCH_ARG0 || exe_name == MISSPELLED_APPLY_PATCH_ARG0 {
code_apply_patch::main();
}
let argv1 = args.next().unwrap_or_default();
if argv1 == CODEX_APPLY_PATCH_ARG1 {
let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned));
let exit_code = match patch_arg {
Some(patch_arg) => {
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
match code_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
Ok(()) => 0,
Err(_) => 1,
}
}
None => {
eprintln!("Error: {CODEX_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument.");
1
}
};
std::process::exit(exit_code);
}
// This modifies the environment, which is not thread-safe, so do this
// before creating any threads/the Tokio runtime.
load_dotenv();
// Retain the TempDir so it exists for the lifetime of the invocation of
// this executable. Admittedly, we could invoke `keep()` on it, but it
// would be nice to avoid leaving temporary directories behind, if possible.
let _path_entry = match prepend_path_entry_for_apply_patch() {
Ok(path_entry) => Some(path_entry),
Err(err) => {
// It is possible that Codex will proceed successfully even if
// updating the PATH fails, so warn the user and move on.
eprintln!("WARNING: proceeding, even though we could not update PATH: {err}");
None
}
};
// Regular invocation – create a Tokio runtime and execute the provided
// async entry-point.
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async move {
let code_linux_sandbox_exe: Option<PathBuf> = if cfg!(target_os = "linux") {
std::env::current_exe().ok()
} else {
None
};
main_fn(code_linux_sandbox_exe).await
})
}
const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
/// Load env vars from ~/.code/.env (legacy ~/.codex/.env is still read) and `$(pwd)/.env`.
///
/// Security: Do not allow `.env` files to create or modify any variables
/// with names starting with `CODEX_`.
fn load_dotenv() {
// 1) Load from global ~/.code/.env (or ~/.codex/.env) first.
if let Ok(code_home) = code_core::config::find_code_home() {
let global_env_path = resolve_code_path_for_read(&code_home, Path::new(".env"));
if let Ok(iter) = dotenvy::from_path_iter(global_env_path) {
// Global env may legitimately contain provider keys for Code usage.
set_filtered(iter);
}
}
// 2) Load from the current project's .env, but with extra safety:
// - Do NOT import provider API keys by default (e.g., OPENAI_API_KEY, AZURE_OPENAI_API_KEY).
// - Users can opt back in via CODEX_ALLOW_PROJECT_OPENAI_KEYS=1 (either exported
// in the shell or placed in ~/.code/.env).
if let Ok(iter) = dotenvy::dotenv_iter() {
// Filtered setter that always blocks provider keys from the project's .env.
for (key, value) in iter.into_iter().flatten() {
let upper = key.to_ascii_uppercase();
// Never allow CODEX_* to be set from .env files for safety.
if upper.starts_with(ILLEGAL_ENV_VAR_PREFIX) { continue; }
// Always ignore provider keys from project .env (must be set globally or in shell).
if upper == "OPENAI_API_KEY" || upper == "AZURE_OPENAI_API_KEY" { continue; }
// Safe: still single-threaded during startup.
unsafe { std::env::set_var(&key, &value) };
}
}
}
/// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys.
fn set_filtered<I>(iter: I)
where
I: IntoIterator<Item = Result<(String, String), dotenvy::Error>>,
{
for (key, value) in iter.into_iter().flatten() {
if !key.to_ascii_uppercase().starts_with(ILLEGAL_ENV_VAR_PREFIX) {
// It is safe to call set_var() because our process is
// single-threaded at this point in its execution.
unsafe { std::env::set_var(&key, &value) };
}
}
}
/// Creates a temporary directory with either:
///
/// - UNIX: `apply_patch` symlink to the current executable
/// - WINDOWS: `apply_patch.bat` batch script to invoke the current executable
/// with the "secret" --codex-run-as-apply-patch flag.
///
/// This temporary directory is prepended to the PATH environment variable so
/// that `apply_patch` can be on the PATH without requiring the user to
/// install a separate `apply_patch` executable, simplifying the deployment of
/// Codex CLI.
///
/// IMPORTANT: This function modifies the PATH environment variable, so it MUST
/// be called before multiple threads are spawned.
fn prepend_path_entry_for_apply_patch() -> std::io::Result<TempDir> {
let temp_dir = TempDir::new()?;
let path = temp_dir.path();
for filename in &[APPLY_PATCH_ARG0, MISSPELLED_APPLY_PATCH_ARG0] {
let exe = std::env::current_exe()?;
#[cfg(unix)]
{
let link = path.join(filename);
symlink(&exe, &link)?;
}
#[cfg(windows)]
{
let batch_script = path.join(format!("{filename}.bat"));
std::fs::write(
&batch_script,
format!(
r#"@echo off
"{}" {CODEX_APPLY_PATCH_ARG1} %*
"#,
exe.display()
),
)?;
}
}
#[cfg(unix)]
const PATH_SEPARATOR: &str = ":";
#[cfg(windows)]
const PATH_SEPARATOR: &str = ";";
let path_element = path.display();
let updated_path_env_var = match std::env::var("PATH") {
Ok(existing_path) => {
format!("{path_element}{PATH_SEPARATOR}{existing_path}")
}
Err(_) => {
format!("{path_element}")
}
};
unsafe {
std::env::set_var("PATH", updated_path_env_var);
}
Ok(temp_dir)
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "code-backend-client"
version = "0.0.0"
edition = "2024"
publish = false
[lib]
path = "src/lib.rs"
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
code-backend-openapi-models = { path = "../code-backend-openapi-models" }
[dev-dependencies]
pretty_assertions = "1"
+244
View File
@@ -0,0 +1,244 @@
use crate::types::CodeTaskDetailsResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use anyhow::Result;
use reqwest::header::AUTHORIZATION;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use reqwest::header::USER_AGENT;
use serde::de::DeserializeOwned;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PathStyle {
/// /api/codex/…
CodexApi,
/// /wham/…
ChatGptApi,
}
impl PathStyle {
pub fn from_base_url(base_url: &str) -> Self {
if base_url.contains("/backend-api") {
PathStyle::ChatGptApi
} else {
PathStyle::CodexApi
}
}
}
#[derive(Clone, Debug)]
pub struct Client {
base_url: String,
http: reqwest::Client,
bearer_token: Option<String>,
user_agent: Option<HeaderValue>,
chatgpt_account_id: Option<String>,
path_style: PathStyle,
}
impl Client {
pub fn new(base_url: impl Into<String>) -> Result<Self> {
let mut base_url = base_url.into();
// Normalize common ChatGPT hostnames to include /backend-api so we hit the WHAM paths.
// Also trim trailing slashes for consistent URL building.
while base_url.ends_with('/') {
base_url.pop();
}
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{base_url}/backend-api");
}
let http = reqwest::Client::builder().build()?;
let path_style = PathStyle::from_base_url(&base_url);
Ok(Self {
base_url,
http,
bearer_token: None,
user_agent: None,
chatgpt_account_id: None,
path_style,
})
}
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.bearer_token = Some(token.into());
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
if let Ok(hv) = HeaderValue::from_str(&ua.into()) {
self.user_agent = Some(hv);
}
self
}
pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> Self {
self.chatgpt_account_id = Some(account_id.into());
self
}
pub fn with_path_style(mut self, style: PathStyle) -> Self {
self.path_style = style;
self
}
fn headers(&self) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(ua) = &self.user_agent {
h.insert(USER_AGENT, ua.clone());
} else {
h.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
}
if let Some(token) = &self.bearer_token {
let value = format!("Bearer {token}");
if let Ok(hv) = HeaderValue::from_str(&value) {
h.insert(AUTHORIZATION, hv);
}
}
if let Some(acc) = &self.chatgpt_account_id
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(hv) = HeaderValue::from_str(acc)
{
h.insert(name, hv);
}
h
}
async fn exec_request(
&self,
req: reqwest::RequestBuilder,
method: &str,
url: &str,
) -> Result<(String, String)> {
let res = req.send().await?;
let status = res.status();
let ct = res
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = res.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("{method} {url} failed: {status}; content-type={ct}; body={body}");
}
Ok((body, ct))
}
fn decode_json<T: DeserializeOwned>(&self, url: &str, ct: &str, body: &str) -> Result<T> {
match serde_json::from_str::<T>(body) {
Ok(v) => Ok(v),
Err(e) => {
anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}");
}
}
}
pub async fn list_tasks(
&self,
limit: Option<i32>,
task_filter: Option<&str>,
environment_id: Option<&str>,
) -> Result<PaginatedListTaskListItem> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/tasks/list", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/tasks/list", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let req = if let Some(lim) = limit {
req.query(&[("limit", lim)])
} else {
req
};
let req = if let Some(tf) = task_filter {
req.query(&[("task_filter", tf)])
} else {
req
};
let req = if let Some(id) = environment_id {
req.query(&[("environment_id", id)])
} else {
req
};
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json::<PaginatedListTaskListItem>(&url, &ct, &body)
}
pub async fn get_task_details(&self, task_id: &str) -> Result<CodeTaskDetailsResponse> {
let (parsed, _body, _ct) = self.get_task_details_with_body(task_id).await?;
Ok(parsed)
}
pub async fn get_task_details_with_body(
&self,
task_id: &str,
) -> Result<(CodeTaskDetailsResponse, String, String)> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/tasks/{}", self.base_url, task_id),
PathStyle::ChatGptApi => format!("{}/wham/tasks/{}", self.base_url, task_id),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let parsed: CodeTaskDetailsResponse = self.decode_json(&url, &ct, &body)?;
Ok((parsed, body, ct))
}
pub async fn list_sibling_turns(
&self,
task_id: &str,
turn_id: &str,
) -> Result<TurnAttemptsSiblingTurnsResponse> {
let url = match self.path_style {
PathStyle::CodexApi => format!(
"{}/api/codex/tasks/{}/turns/{}/sibling_turns",
self.base_url, task_id, turn_id
),
PathStyle::ChatGptApi => format!(
"{}/wham/tasks/{}/turns/{}/sibling_turns",
self.base_url, task_id, turn_id
),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
}
/// Create a new task (user turn) by POSTing to the appropriate backend path
/// based on `path_style`. Returns the created task id.
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/tasks", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/tasks", self.base_url),
};
let req = self
.http
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&request_body);
let (body, ct) = self.exec_request(req, "POST", &url).await?;
// Extract id from JSON: prefer `task.id`; fallback to top-level `id` when present.
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => {
if let Some(id) = v
.get("task")
.and_then(|t| t.get("id"))
.and_then(|s| s.as_str())
{
Ok(id.to_string())
} else if let Some(id) = v.get("id").and_then(|s| s.as_str()) {
Ok(id.to_string())
} else {
anyhow::bail!(
"POST {url} succeeded but no task id found; content-type={ct}; body={body}"
);
}
}
Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
mod client;
pub mod types;
pub use client::Client;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::PaginatedListTaskListItem;
pub use types::TaskListItem;
pub use types::TurnAttemptsSiblingTurnsResponse;
+369
View File
@@ -0,0 +1,369 @@
pub use code_backend_openapi_models::models::PaginatedListTaskListItem;
pub use code_backend_openapi_models::models::TaskListItem;
use serde::Deserialize;
use serde::de::Deserializer;
use serde_json::Value;
use std::collections::HashMap;
/// Hand-rolled models for the Cloud Tasks task-details response.
/// The generated OpenAPI models are pretty bad. This is a half-step
/// towards hand-rolling them.
#[derive(Clone, Debug, Deserialize)]
pub struct CodeTaskDetailsResponse {
#[serde(default)]
pub current_user_turn: Option<Turn>,
#[serde(default)]
pub current_assistant_turn: Option<Turn>,
#[serde(default)]
pub current_diff_task_turn: Option<Turn>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Turn {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub attempt_placement: Option<i64>,
#[serde(default, rename = "turn_status")]
pub turn_status: Option<String>,
#[serde(default, deserialize_with = "deserialize_vec")]
pub sibling_turn_ids: Vec<String>,
#[serde(default, deserialize_with = "deserialize_vec")]
pub input_items: Vec<TurnItem>,
#[serde(default, deserialize_with = "deserialize_vec")]
pub output_items: Vec<TurnItem>,
#[serde(default)]
pub worklog: Option<Worklog>,
#[serde(default)]
pub error: Option<TurnError>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TurnItem {
#[serde(rename = "type", default)]
pub kind: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default, deserialize_with = "deserialize_vec")]
pub content: Vec<ContentFragment>,
#[serde(default)]
pub diff: Option<String>,
#[serde(default)]
pub output_diff: Option<DiffPayload>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
pub enum ContentFragment {
Structured(StructuredContent),
Text(String),
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct StructuredContent {
#[serde(rename = "content_type", default)]
pub content_type: Option<String>,
#[serde(default)]
pub text: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct DiffPayload {
#[serde(default)]
pub diff: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Worklog {
#[serde(default, deserialize_with = "deserialize_vec")]
pub messages: Vec<WorklogMessage>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct WorklogMessage {
#[serde(default)]
pub author: Option<Author>,
#[serde(default)]
pub content: Option<WorklogContent>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Author {
#[serde(default)]
pub role: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct WorklogContent {
#[serde(default)]
pub parts: Vec<ContentFragment>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct TurnError {
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub message: Option<String>,
}
impl ContentFragment {
fn text(&self) -> Option<&str> {
match self {
ContentFragment::Structured(inner) => {
if inner
.content_type
.as_deref()
.map(|ct| ct.eq_ignore_ascii_case("text"))
.unwrap_or(false)
{
inner.text.as_deref().filter(|s| !s.is_empty())
} else {
None
}
}
ContentFragment::Text(raw) => {
if raw.trim().is_empty() {
None
} else {
Some(raw.as_str())
}
}
}
}
}
impl TurnItem {
fn text_values(&self) -> Vec<String> {
self.content
.iter()
.filter_map(|fragment| fragment.text().map(str::to_string))
.collect()
}
fn diff_text(&self) -> Option<String> {
if self.kind == "output_diff" {
if let Some(diff) = &self.diff
&& !diff.is_empty()
{
return Some(diff.clone());
}
} else if self.kind == "pr"
&& let Some(payload) = &self.output_diff
&& let Some(diff) = &payload.diff
&& !diff.is_empty()
{
return Some(diff.clone());
}
None
}
}
impl Turn {
fn unified_diff(&self) -> Option<String> {
self.output_items.iter().find_map(TurnItem::diff_text)
}
fn message_texts(&self) -> Vec<String> {
let mut out: Vec<String> = self
.output_items
.iter()
.filter(|item| item.kind == "message")
.flat_map(TurnItem::text_values)
.collect();
if let Some(log) = &self.worklog {
for message in &log.messages {
if message.is_assistant() {
out.extend(message.text_values());
}
}
}
out
}
fn user_prompt(&self) -> Option<String> {
let parts: Vec<String> = self
.input_items
.iter()
.filter(|item| item.kind == "message")
.filter(|item| {
item.role
.as_deref()
.map(|r| r.eq_ignore_ascii_case("user"))
.unwrap_or(true)
})
.flat_map(TurnItem::text_values)
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join(
"
",
))
}
}
fn error_summary(&self) -> Option<String> {
self.error.as_ref().and_then(TurnError::summary)
}
}
impl WorklogMessage {
fn is_assistant(&self) -> bool {
self.author
.as_ref()
.and_then(|a| a.role.as_deref())
.map(|role| role.eq_ignore_ascii_case("assistant"))
.unwrap_or(false)
}
fn text_values(&self) -> Vec<String> {
self.content
.as_ref()
.map(|content| {
content
.parts
.iter()
.filter_map(|fragment| fragment.text().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
}
impl TurnError {
fn summary(&self) -> Option<String> {
let code = self.code.as_deref().unwrap_or("");
let message = self.message.as_deref().unwrap_or("");
match (code.is_empty(), message.is_empty()) {
(true, true) => None,
(false, true) => Some(code.to_string()),
(true, false) => Some(message.to_string()),
(false, false) => Some(format!("{code}: {message}")),
}
}
}
pub trait CodeTaskDetailsResponseExt {
/// Attempt to extract a unified diff string from the assistant or diff turn.
fn unified_diff(&self) -> Option<String>;
/// Extract assistant text output messages (no diff) from current turns.
fn assistant_text_messages(&self) -> Vec<String>;
/// Extract the user's prompt text from the current user turn, when present.
fn user_text_prompt(&self) -> Option<String>;
/// Extract an assistant error message (if the turn failed and provided one).
fn assistant_error_message(&self) -> Option<String>;
}
impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse {
fn unified_diff(&self) -> Option<String> {
[
self.current_diff_task_turn.as_ref(),
self.current_assistant_turn.as_ref(),
]
.into_iter()
.flatten()
.find_map(Turn::unified_diff)
}
fn assistant_text_messages(&self) -> Vec<String> {
let mut out = Vec::new();
for turn in [
self.current_diff_task_turn.as_ref(),
self.current_assistant_turn.as_ref(),
]
.into_iter()
.flatten()
{
out.extend(turn.message_texts());
}
out
}
fn user_text_prompt(&self) -> Option<String> {
self.current_user_turn.as_ref().and_then(Turn::user_prompt)
}
fn assistant_error_message(&self) -> Option<String> {
self.current_assistant_turn
.as_ref()
.and_then(Turn::error_summary)
}
}
fn deserialize_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
}
#[derive(Clone, Debug, Deserialize)]
pub struct TurnAttemptsSiblingTurnsResponse {
#[serde(default)]
pub sibling_turns: Vec<HashMap<String, Value>>,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn fixture(name: &str) -> CodeTaskDetailsResponse {
let json = match name {
"diff" => include_str!("../tests/fixtures/task_details_with_diff.json"),
"error" => include_str!("../tests/fixtures/task_details_with_error.json"),
other => panic!("unknown fixture {other}"),
};
serde_json::from_str(json).expect("fixture should deserialize")
}
#[test]
fn unified_diff_prefers_current_diff_task_turn() {
let details = fixture("diff");
let diff = details.unified_diff().expect("diff present");
assert!(diff.contains("diff --git"));
}
#[test]
fn unified_diff_falls_back_to_pr_output_diff() {
let details = fixture("error");
let diff = details.unified_diff().expect("diff from pr output");
assert!(diff.contains("lib.rs"));
}
#[test]
fn assistant_text_messages_extracts_text_content() {
let details = fixture("diff");
let messages = details.assistant_text_messages();
assert_eq!(messages, vec!["Assistant response".to_string()]);
}
#[test]
fn user_text_prompt_joins_parts_with_spacing() {
let details = fixture("diff");
let prompt = details.user_text_prompt().expect("prompt present");
assert_eq!(
prompt,
"First line
Second line"
);
}
#[test]
fn assistant_error_message_combines_code_and_message() {
let details = fixture("error");
let msg = details
.assistant_error_message()
.expect("error should be present");
assert_eq!(msg, "APPLY_FAILED: Patch could not be applied");
}
}
@@ -0,0 +1,38 @@
{
"task": {
"id": "task_123",
"title": "Refactor cloud task client",
"archived": false,
"external_pull_requests": []
},
"current_user_turn": {
"input_items": [
{
"type": "message",
"role": "user",
"content": [
{ "content_type": "text", "text": "First line" },
{ "content_type": "text", "text": "Second line" }
]
}
]
},
"current_assistant_turn": {
"output_items": [
{
"type": "message",
"content": [
{ "content_type": "text", "text": "Assistant response" }
]
}
]
},
"current_diff_task_turn": {
"output_items": [
{
"type": "output_diff",
"diff": "diff --git a/src/main.rs b/src/main.rs\n+fn main() { println!(\"hi\"); }\n"
}
]
}
}
@@ -0,0 +1,22 @@
{
"task": {
"id": "task_456",
"title": "Investigate failure",
"archived": false,
"external_pull_requests": []
},
"current_assistant_turn": {
"output_items": [
{
"type": "pr",
"output_diff": {
"diff": "diff --git a/lib.rs b/lib.rs\n+pub fn hello() {}\n"
}
}
],
"error": {
"code": "APPLY_FAILED",
"message": "Patch could not be applied"
}
}
}
+36
View File
@@ -0,0 +1,36 @@
[package]
edition = "2024"
name = "code-browser"
version = { workspace = true }
[lib]
name = "code_browser"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = "1"
async-trait = "0.1"
base64 = "0.22.1"
bytes = "1"
chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-features = false }
chromiumoxide_types = "0.7"
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"
once_cell = "1.20"
rand = "0.9"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
url = "2"
thiserror = "2"
tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread", "sync", "time"] }
tracing = "0.1"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tokio-test = "0.4"
+28
View File
@@ -0,0 +1,28 @@
<svg class="vc-pointer" xmlns="http://www.w3.org/2000/svg" width="40px" viewBox="0 0 40 30" aria-hidden="true" height="30px" xmlns:bx="https://boxy-svg.com">
<path d="M 14.331 9.906 C 14.331 9.906 0.73 0.648 0.701 0.667 L 1.542 16.615 C 1.542 17.315 2.342 17.815 2.942 17.415 L 7.219 12.874 L 13.831 11.606 C 14.631 11.506 14.954 10.465 14.331 9.906 Z" stroke="white" stroke-width="1.6" style="fill: rgb(0, 171, 255); stroke-linejoin: round; transform-origin: 17.865px 12.857px; filter: url(#drop-shadow-filter-0);"></path>
<rect style="stroke-width: 1.6px; fill: rgb(0, 171, 255); transform-origin: 21.412px 16.923px;" x="10.82" y="18.564" width="28.228" height="10.691" rx="5.3" ry="5.3"></rect>
<defs>
<filter id="drop-shadow-filter-0" bx:preset="drop-shadow 1 0 0 1 0.39 rgba(0,0,0,0.3)" color-interpolation-filters="sRGB" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="1"></feGaussianBlur>
<feOffset dx="0" dy="0"></feOffset>
<feComponentTransfer result="offsetblur">
<feFuncA id="spread-ctrl" type="linear" slope="0.78"></feFuncA>
</feComponentTransfer>
<feFlood flood-color="rgba(0,0,0,0.3)"></feFlood>
<feComposite in2="offsetblur" operator="in"></feComposite>
<feMerge>
<feMergeNode></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<radialGradient gradientUnits="userSpaceOnUse" cx="24.968" cy="23.938" r="9.991" id="gradient-1">
<stop offset="0" style="stop-color: rgb(100% 100% 100%)"></stop>
<stop offset="1" style="stop-color: rgb(56.687% 56.687% 56.687%)"></stop>
</radialGradient>
<linearGradient gradientUnits="userSpaceOnUse" x1="24.968" y1="19.857" x2="24.968" y2="28.019" id="gradient-0" gradientTransform="matrix(-0.003677, 0.999994, -2.156498, -0.007931, 76.341039, -0.348643)" spreadMethod="pad">
<stop offset="0" style="stop-color: rgb(225, 170, 40);"></stop>
<stop offset="1" style="stop-color: rgb(75, 205, 234);"></stop>
</linearGradient>
</defs>
<path d="M 19.269 24.657 L 19.96 24.832 Q 19.743 25.683 19.178 26.131 Q 18.613 26.578 17.796 26.578 Q 16.952 26.578 16.422 26.234 Q 15.893 25.89 15.617 25.238 Q 15.341 24.586 15.341 23.837 Q 15.341 23.021 15.652 22.413 Q 15.964 21.805 16.54 21.49 Q 17.116 21.174 17.807 21.174 Q 18.591 21.174 19.126 21.574 Q 19.661 21.973 19.871 22.696 L 19.19 22.857 Q 19.008 22.287 18.663 22.026 Q 18.317 21.766 17.793 21.766 Q 17.191 21.766 16.786 22.055 Q 16.381 22.344 16.217 22.83 Q 16.053 23.317 16.053 23.833 Q 16.053 24.5 16.248 24.997 Q 16.442 25.495 16.852 25.74 Q 17.262 25.986 17.739 25.986 Q 18.32 25.986 18.723 25.651 Q 19.126 25.316 19.269 24.657 Z M 20.491 24.596 Q 20.491 23.545 21.076 23.039 Q 21.564 22.618 22.266 22.618 Q 23.047 22.618 23.542 23.13 Q 24.038 23.641 24.038 24.543 Q 24.038 25.274 23.818 25.692 Q 23.599 26.111 23.18 26.343 Q 22.762 26.575 22.266 26.575 Q 21.471 26.575 20.981 26.065 Q 20.491 25.555 20.491 24.596 Z M 21.15 24.596 Q 21.15 25.323 21.468 25.685 Q 21.785 26.047 22.266 26.047 Q 22.744 26.047 23.061 25.683 Q 23.378 25.32 23.378 24.575 Q 23.378 23.873 23.059 23.511 Q 22.74 23.149 22.266 23.149 Q 21.785 23.149 21.468 23.509 Q 21.15 23.869 21.15 24.596 Z M 27.245 26.489 L 27.245 26.011 Q 26.885 26.575 26.186 26.575 Q 25.734 26.575 25.354 26.325 Q 24.974 26.076 24.766 25.628 Q 24.557 25.181 24.557 24.6 Q 24.557 24.033 24.746 23.571 Q 24.935 23.11 25.313 22.864 Q 25.691 22.618 26.158 22.618 Q 26.5 22.618 26.767 22.762 Q 27.035 22.907 27.202 23.138 L 27.202 21.264 L 27.84 21.264 L 27.84 26.489 Z M 25.217 24.6 Q 25.217 25.327 25.523 25.687 Q 25.83 26.047 26.247 26.047 Q 26.667 26.047 26.961 25.703 Q 27.256 25.359 27.256 24.653 Q 27.256 23.876 26.956 23.513 Q 26.657 23.149 26.218 23.149 Q 25.791 23.149 25.504 23.498 Q 25.217 23.848 25.217 24.6 Z M 31.44 25.27 L 32.103 25.352 Q 31.946 25.933 31.522 26.254 Q 31.098 26.575 30.439 26.575 Q 29.608 26.575 29.122 26.063 Q 28.635 25.552 28.635 24.628 Q 28.635 23.673 29.127 23.146 Q 29.619 22.618 30.403 22.618 Q 31.162 22.618 31.643 23.135 Q 32.125 23.652 32.125 24.589 Q 32.125 24.646 32.121 24.76 L 29.298 24.76 Q 29.334 25.384 29.651 25.716 Q 29.968 26.047 30.442 26.047 Q 30.795 26.047 31.045 25.862 Q 31.294 25.676 31.44 25.27 Z M 29.334 24.233 L 31.447 24.233 Q 31.405 23.755 31.205 23.516 Q 30.899 23.146 30.41 23.146 Q 29.968 23.146 29.667 23.441 Q 29.366 23.737 29.334 24.233 Z M 32.902 26.489 L 32.902 22.704 L 33.479 22.704 L 33.479 23.277 Q 33.7 22.875 33.887 22.746 Q 34.075 22.618 34.299 22.618 Q 34.623 22.618 34.959 22.825 L 34.738 23.42 Q 34.502 23.281 34.267 23.281 Q 34.057 23.281 33.889 23.408 Q 33.722 23.534 33.65 23.759 Q 33.543 24.101 33.543 24.507 L 33.543 26.489 Z" style="fill: rgb(255, 255, 255); paint-order: fill; text-wrap-mode: nowrap;"></path>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+150
View File
@@ -0,0 +1,150 @@
use crate::Result;
use crate::config::ImageFormat;
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageRef {
pub path: String,
pub mime: String,
pub width: u32,
pub height: u32,
pub ttl_ms: u64,
pub created_at: DateTime<Utc>,
}
pub struct AssetManager {
base_dir: PathBuf,
#[allow(dead_code)]
session_id: String,
assets: Arc<RwLock<HashMap<String, ImageRef>>>,
}
impl AssetManager {
pub async fn new() -> Result<Self> {
let session_id = Uuid::new_v4().to_string();
let base_dir = PathBuf::from("/tmp/codex/browser").join(&session_id);
fs::create_dir_all(&base_dir).await?;
Ok(Self {
base_dir,
session_id,
assets: Arc::new(RwLock::new(HashMap::new())),
})
}
pub async fn store_screenshot(
&self,
data: &[u8],
format: ImageFormat,
width: u32,
height: u32,
ttl_ms: u64,
) -> Result<ImageRef> {
let filename = format!(
"{}.{}",
Uuid::new_v4(),
match format {
ImageFormat::Png => "png",
ImageFormat::Webp => "webp",
}
);
let path = self.base_dir.join(&filename);
fs::write(&path, data).await?;
let mime = match format {
ImageFormat::Png => "image/png",
ImageFormat::Webp => "image/webp",
}
.to_string();
let image_ref = ImageRef {
path: path.to_string_lossy().to_string(),
mime,
width,
height,
ttl_ms,
created_at: Utc::now(),
};
let mut assets = self.assets.write().await;
assets.insert(filename, image_ref.clone());
Ok(image_ref)
}
pub async fn store_screenshots(
&self,
screenshots: Vec<crate::page::Screenshot>,
ttl_ms: u64,
) -> Result<Vec<ImageRef>> {
let mut refs = Vec::new();
for screenshot in screenshots {
let image_ref = self
.store_screenshot(
&screenshot.data,
screenshot.format,
screenshot.width,
screenshot.height,
ttl_ms,
)
.await?;
refs.push(image_ref);
}
Ok(refs)
}
pub async fn cleanup_expired(&self) -> Result<()> {
let now = Utc::now();
let mut assets = self.assets.write().await;
let mut to_remove = Vec::new();
for (key, asset) in assets.iter() {
let age = now - asset.created_at;
if age > Duration::milliseconds(asset.ttl_ms as i64) {
to_remove.push(key.clone());
let _ = fs::remove_file(&asset.path).await;
}
}
for key in to_remove {
assets.remove(&key);
}
Ok(())
}
pub async fn cleanup_all(&self) -> Result<()> {
if self.base_dir.exists() {
fs::remove_dir_all(&self.base_dir).await?;
}
Ok(())
}
pub fn get_session_dir(&self) -> &Path {
&self.base_dir
}
}
impl Drop for AssetManager {
fn drop(&mut self) {
let base_dir = self.base_dir.clone();
tokio::spawn(async move {
let _ = fs::remove_dir_all(&base_dir).await;
});
}
}
+169
View File
@@ -0,0 +1,169 @@
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_viewport")]
pub viewport: ViewportConfig,
#[serde(default = "default_wait")]
pub wait: WaitStrategy,
#[serde(default)]
pub fullpage: bool,
#[serde(default = "default_segments_max")]
pub segments_max: usize,
#[serde(default = "default_idle_timeout_ms")]
pub idle_timeout_ms: u64,
#[serde(default = "default_format")]
pub format: ImageFormat,
/// Launch Chrome in headless mode. Prefer headed for fewer false positives.
#[serde(default)]
pub headless: bool,
/// Connect to an already-running Chrome DevTools WS endpoint
/// e.g. ws://127.0.0.1:9222/devtools/browser/XXXXXXXX
#[serde(default)]
pub connect_ws: Option<String>,
/// Or discover the WS endpoint from a --remote-debugging-port (e.g. 9222).
#[serde(default)]
pub connect_port: Option<u16>,
/// Use a persistent profile instead of temp. If set, we won't delete it.
#[serde(default)]
pub user_data_dir: Option<PathBuf>,
/// If true and `user_data_dir` is Some, never delete on drop.
#[serde(default = "default_persist_profile")]
pub persist_profile: bool,
/// "Human" env hints applied via CDP immediately after page creation.
#[serde(default)]
pub locale: Option<String>, // e.g. Some("en-AU".into())
#[serde(default)]
pub timezone: Option<String>, // e.g. Some("Australia/Brisbane".into())
#[serde(default)]
pub accept_language: Option<String>, // e.g. Some("en-AU,en;q=0.9".into())
#[serde(default)]
pub user_agent: Option<String>, // leave None to let Chrome decide
// --- Connection tuning (CDP attach) ---
/// Optional host to use when connecting to an external Chrome via
/// `connect_port`. Defaults to 127.0.0.1 when not set.
#[serde(default)]
pub connect_host: Option<String>,
/// Per-attempt timeout for WS connect to Chrome (milliseconds)
#[serde(default = "default_connect_attempt_timeout_ms")]
pub connect_attempt_timeout_ms: u64,
/// Number of WS connect attempts before giving up
#[serde(default = "default_connect_attempts")]
pub connect_attempts: u32,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
enabled: false,
viewport: default_viewport(),
wait: default_wait(),
fullpage: false,
segments_max: default_segments_max(),
idle_timeout_ms: default_idle_timeout_ms(),
format: default_format(),
headless: false, // Prefer headed for fewer false positives
connect_ws: None,
connect_port: None,
connect_host: None,
user_data_dir: None,
persist_profile: default_persist_profile(),
locale: Some("en-AU".into()),
timezone: Some("Australia/Brisbane".into()),
accept_language: Some("en-AU,en;q=0.9".into()),
user_agent: None,
connect_attempt_timeout_ms: default_connect_attempt_timeout_ms(),
connect_attempts: default_connect_attempts(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewportConfig {
pub width: u32,
pub height: u32,
#[serde(default = "default_device_scale_factor")]
pub device_scale_factor: f64,
#[serde(default)]
pub mobile: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum WaitStrategy {
Event(String),
Delay { delay_ms: u64 },
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageFormat {
Png,
Webp,
}
fn default_viewport() -> ViewportConfig {
ViewportConfig {
width: 1024,
height: 768,
device_scale_factor: 1.0,
mobile: false,
}
}
fn default_wait() -> WaitStrategy {
// "load" is more reliable than a fixed 1s "networkidle" sleep in our
// navigation implementation and better matches SPA hydration delays.
WaitStrategy::Event("load".to_string())
}
fn default_segments_max() -> usize {
8
}
fn default_idle_timeout_ms() -> u64 {
60000
}
fn default_device_scale_factor() -> f64 {
1.0
}
fn default_format() -> ImageFormat {
ImageFormat::Png
}
fn default_persist_profile() -> bool {
true
}
fn default_connect_attempt_timeout_ms() -> u64 {
3000
}
fn default_connect_attempts() -> u32 {
3
}
+63
View File
@@ -0,0 +1,63 @@
use crate::config::BrowserConfig;
use crate::manager::BrowserManager;
use once_cell::sync::Lazy;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Global browser manager instance shared between TUI and Session
static GLOBAL_BROWSER_MANAGER: Lazy<Arc<RwLock<Option<Arc<BrowserManager>>>>> =
Lazy::new(|| Arc::new(RwLock::new(None)));
/// Cache of the last successful external Chrome connection (port/ws)
static LAST_CONNECTION: Lazy<Arc<RwLock<(Option<u16>, Option<String>)>>> =
Lazy::new(|| Arc::new(RwLock::new((None, None))));
/// Get or create the global browser manager
pub async fn get_or_create_browser_manager() -> Arc<BrowserManager> {
// Fast path: try read lock to avoid contending on writer when already initialized
if let Some(existing) = GLOBAL_BROWSER_MANAGER.read().await.as_ref().cloned() {
return existing;
}
// Slow path: acquire write lock and initialize if still empty
let mut w = GLOBAL_BROWSER_MANAGER.write().await;
if let Some(existing) = w.as_ref() {
return existing.clone();
}
let config = BrowserConfig::default();
let manager = Arc::new(BrowserManager::new(config));
*w = Some(manager.clone());
manager
}
/// Get the global browser manager if it exists
pub async fn get_browser_manager() -> Option<Arc<BrowserManager>> {
GLOBAL_BROWSER_MANAGER.read().await.as_ref().cloned()
}
/// Clear the global browser manager
pub async fn clear_browser_manager() {
*GLOBAL_BROWSER_MANAGER.write().await = None;
}
/// Set the global browser manager configuration (used by TUI to sync with global state)
pub async fn set_global_browser_manager(manager: Arc<BrowserManager>) {
let mut guard = GLOBAL_BROWSER_MANAGER.write().await;
*guard = Some(manager);
tracing::info!("Global browser manager set");
}
/// Get the last known external Chrome connection (port, ws)
pub async fn get_last_connection() -> (Option<u16>, Option<String>) {
let (port, ws) = LAST_CONNECTION.read().await.clone();
(port, ws)
}
/// Update the last known external Chrome connection (port, ws)
pub async fn set_last_connection(port: Option<u16>, ws: Option<String>) {
let mut guard = LAST_CONNECTION.write().await;
// Clone ws for logging to avoid use after move
let ws_for_log = ws.clone();
*guard = (port, ws);
tracing::debug!("Updated last Chrome connection cache: port={:?}, ws={:?}", port, ws_for_log);
}
@@ -0,0 +1,96 @@
use crate::Result;
use crate::assets::AssetManager;
use crate::assets::ImageRef;
use crate::manager::BrowserManager;
use crate::page::ScreenshotMode;
use std::sync::Arc;
use tracing::debug;
use tracing::info;
pub struct BrowserInjector {
manager: Arc<BrowserManager>,
asset_manager: Arc<AssetManager>,
}
impl BrowserInjector {
pub fn new(manager: Arc<BrowserManager>, asset_manager: Arc<AssetManager>) -> Self {
Self {
manager,
asset_manager,
}
}
pub async fn inject_pre_llm_call(&self) -> Result<Option<InjectionResult>> {
if !self.manager.is_enabled().await {
return Ok(None);
}
debug!("Browser enabled, capturing pre-LLM screenshot");
let page = match self.manager.get_or_create_page().await {
Ok(p) => p,
Err(_) => {
info!("No active page, using about:blank");
let page = self.manager.get_or_create_page().await?;
page.goto("about:blank", None).await?;
page
}
};
let config = self.manager.get_config().await;
let mode = if config.fullpage {
ScreenshotMode::FullPage {
segments_max: Some(config.segments_max),
}
} else {
ScreenshotMode::Viewport
};
let screenshots = page.screenshot(mode).await?;
let ttl_ms = 300000;
let images = self
.asset_manager
.store_screenshots(screenshots, ttl_ms)
.await?;
let current_url = page
.get_current_url()
.await
.unwrap_or_else(|_| "about:blank".to_string());
let system_hint = format!(
"A fresh screenshot of the active page ({}) is attached; use browser_* tools to navigate or capture more.",
current_url
);
let segments_captured = images.len();
Ok(Some(InjectionResult {
images,
system_hint,
metadata: InjectionMetadata {
url: current_url,
fullpage: config.fullpage,
segments_captured,
},
}))
}
pub async fn cleanup_expired(&self) -> Result<()> {
self.asset_manager.cleanup_expired().await
}
}
#[derive(Debug)]
pub struct InjectionResult {
pub images: Vec<ImageRef>,
pub system_hint: String,
pub metadata: InjectionMetadata,
}
#[derive(Debug)]
pub struct InjectionMetadata {
pub url: String,
pub fullpage: bool,
pub segments_captured: usize,
}
+4
View File
@@ -0,0 +1,4 @@
pub mod browser_injector;
pub use browser_injector::BrowserInjector;
pub use browser_injector::InjectionResult;
+684
View File
@@ -0,0 +1,684 @@
// Virtual Cursor installer (full original code), externalized for easier iteration.
(function () {
function __vcInstall(x, y) {
try {
const ns = 'http://www.w3.org/2000/svg';
const VIEW_W = 40, VIEW_H = 30; // original viewBox of your assets
const ARROW_SIZE_PX = 53; // arrow visual width (px)
const BADGE_SIZE_PX = 70; // badge visual width (px)
const TIP_X = 3, TIP_Y = 3; // tip calibration (px) — adjust if needed
const BADGE_OFF_X = -4, BADGE_OFF_Y = -8;
function ensureRoot() {
let root = document.getElementById('__virtualCursorRoot');
if (!root) {
root = document.createElement('div');
root.id = '__virtualCursorRoot';
Object.assign(root.style, {
position: 'fixed',
inset: '0', // cover viewport -> non-zero paint area
pointerEvents: 'none',
zIndex: '2147483647',
contain: 'layout style', // avoid 'paint' or you'll clip to root box
overflow: 'visible',
});
(document.body || document.documentElement).appendChild(root);
}
return root;
}
function createSvg(tag) { return document.createElementNS(ns, tag); }
// --- Debug logging helpers ---
const DEBUG = true;
function log() { try { console.debug('[VC]', ...arguments); } catch (e) { } }
function warn() { try { console.warn('[VC]', ...arguments); } catch (e) { } }
function info() { try { console.info('[VC]', ...arguments); } catch (e) { } }
// Install once or upgrade from bootstrap version
if (!window.__vc) {
const root = ensureRoot();
log('init', { v: 11, href: location.href, vis: document.visibilityState, prm: (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) });
// --- Arrow SVG container ---
const arrow = createSvg('svg');
arrow.setAttribute('viewBox', '0 0 44 34');
arrow.setAttribute('aria-hidden', 'true');
arrow.style.position = 'absolute';
arrow.style.transformOrigin = '0 0';
arrow.style.width = ARROW_SIZE_PX + 'px';
arrow.style.height = 'auto';
// Ensure no clipping when arrow rotates/translates beyond its viewBox
try { arrow.style.overflow = 'visible'; } catch (_) { }
try { arrow.setAttribute('overflow', 'visible'); } catch (_) { }
try { arrow.style.transformBox = 'view-box'; } catch (_) { }
// defs + drop-shadow filter (your values)
const defs = createSvg('defs');
const filt = createSvg('filter');
filt.setAttribute('id', 'vc-drop-shadow');
filt.setAttribute('color-interpolation-filters', 'sRGB');
filt.setAttribute('x', '-50%');
filt.setAttribute('y', '-50%');
filt.setAttribute('width', '200%');
filt.setAttribute('height', '200%');
const blur = createSvg('feGaussianBlur'); blur.setAttribute('in', 'SourceAlpha'); blur.setAttribute('stdDeviation', '1.5');
const off = createSvg('feOffset'); off.setAttribute('dx', '0'); off.setAttribute('dy', '0');
const ct = createSvg('feComponentTransfer'); ct.setAttribute('result', 'offsetblur');
const fa = createSvg('feFuncA'); fa.setAttribute('type', 'linear'); fa.setAttribute('slope', '0.35'); ct.appendChild(fa);
const flood = createSvg('feFlood'); flood.setAttribute('flood-color', '#000'); flood.setAttribute('flood-opacity', '0.35');
const comp = createSvg('feComposite'); comp.setAttribute('in2', 'offsetblur'); comp.setAttribute('operator', 'in');
const merge = createSvg('feMerge');
const m1 = createSvg('feMergeNode');
const m2 = createSvg('feMergeNode'); m2.setAttribute('in', 'SourceGraphic');
merge.appendChild(m1); merge.appendChild(m2);
filt.appendChild(blur); filt.appendChild(off); filt.appendChild(ct); filt.appendChild(flood); filt.appendChild(comp); filt.appendChild(merge);
defs.appendChild(filt);
arrow.appendChild(defs);
const arrowInner = createSvg('g');
// Ensure rotation pivots around the tip; use SVG CSS transform box
try {
arrowInner.style.transformBox = 'fill-box';
arrowInner.style.transformOrigin = TIP_X + 'px ' + TIP_Y + 'px';
} catch (_) { }
const arrowPath = createSvg('path');
arrowPath.setAttribute('d',
'M 16.63 12.239 C 16.63 12.239 3.029 2.981 3 3 L 3.841 18.948 C 3.841 19.648 4.641 20.148 5.241 19.748 L 9.518 15.207 L 16.13 13.939 C 16.93 13.839 17.253 12.798 16.63 12.239 Z'
);
arrowPath.setAttribute('stroke', 'white');
arrowPath.setAttribute('stroke-width', '1');
arrowPath.setAttribute('vector-effect', 'non-scaling-stroke');
arrowPath.setAttribute('fill', 'rgb(0, 171, 255)');
arrowPath.style.strokeLinejoin = 'round';
arrowPath.setAttribute('filter', 'url(#vc-drop-shadow)');
arrowInner.appendChild(arrowPath);
arrow.appendChild(arrowInner);
// Ensure arrow renders above the badge
try { arrow.style.zIndex = '2'; } catch (_) { }
// --- Badge SVG container ---
const badge = createSvg('svg');
badge.setAttribute('viewBox', '0 0 44 34');
badge.setAttribute('aria-hidden', 'true');
badge.style.position = 'absolute';
badge.style.transformOrigin = '0 0';
badge.style.width = BADGE_SIZE_PX + 'px';
badge.style.height = 'auto';
const badgeInner = createSvg('g');
const rect = createSvg('rect');
rect.setAttribute('x', '10.82');
rect.setAttribute('y', '18.564');
rect.setAttribute('width', '25.686');
rect.setAttribute('height', '10.691');
rect.setAttribute('rx', '4');
rect.setAttribute('ry', '4');
rect.setAttribute('fill', 'rgb(0, 171, 255)');
rect.setAttribute('stroke', 'white');
rect.setAttribute('stroke-width', '1');
rect.setAttribute('vector-effect', 'non-scaling-stroke');
rect.setAttribute('filter', 'url(#vc-drop-shadow)');
badge.appendChild(defs.cloneNode(true));
const glyphs = createSvg('path');
glyphs.setAttribute('d',
'M 19.269 24.657 L 19.96 24.832 C 19.815 25.399 19.555 25.832 19.178 26.131 C 18.801 26.429 18.341 26.578 17.796 26.578 C 17.233 26.578 16.775 26.463 16.422 26.234 C 16.069 26.005 15.801 25.673 15.617 25.238 C 15.433 24.803 15.341 24.336 15.341 23.837 C 15.341 23.293 15.445 22.818 15.652 22.413 C 15.86 22.008 16.156 21.7 16.54 21.49 C 16.924 21.279 17.346 21.174 17.807 21.174 C 18.33 21.174 18.769 21.307 19.126 21.574 C 19.483 21.84 19.731 22.214 19.871 22.696 L 19.19 22.857 C 19.069 22.477 18.893 22.2 18.663 22.026 C 18.432 21.853 18.142 21.766 17.793 21.766 C 17.392 21.766 17.056 21.862 16.786 22.055 C 16.516 22.248 16.326 22.506 16.217 22.83 C 16.108 23.155 16.053 23.489 16.053 23.833 C 16.053 24.278 16.118 24.666 16.248 24.997 C 16.377 25.329 16.579 25.577 16.852 25.74 C 17.125 25.904 17.421 25.986 17.739 25.986 C 18.126 25.986 18.454 25.874 18.723 25.651 C 18.992 25.428 19.174 25.096 19.269 24.657 Z M 20.491 24.596 C 20.491 23.895 20.686 23.376 21.076 23.039 C 21.401 22.758 21.798 22.618 22.266 22.618 C 22.787 22.618 23.212 22.789 23.542 23.13 C 23.873 23.471 24.038 23.942 24.038 24.543 C 24.038 25.03 23.965 25.413 23.818 25.692 C 23.672 25.971 23.459 26.188 23.18 26.343 C 22.901 26.498 22.597 26.575 22.266 26.575 C 21.736 26.575 21.308 26.405 20.981 26.065 C 20.654 25.725 20.491 25.235 20.491 24.596 Z M 21.15 24.596 C 21.15 25.081 21.256 25.444 21.468 25.685 C 21.679 25.926 21.945 26.047 22.266 26.047 C 22.585 26.047 22.85 25.926 23.061 25.683 C 23.272 25.441 23.378 25.072 23.378 24.575 C 23.378 24.107 23.272 23.752 23.059 23.511 C 22.846 23.27 22.582 23.149 22.266 23.149 C 21.945 23.149 21.679 23.269 21.468 23.509 C 21.256 23.749 21.15 24.111 21.15 24.596 Z M 27.245 26.489 L 27.245 26.011 C 27.005 26.387 26.652 26.575 26.186 26.575 C 25.885 26.575 25.607 26.492 25.354 26.325 C 25.101 26.159 24.905 25.927 24.766 25.628 C 24.627 25.33 24.557 24.987 24.557 24.6 C 24.557 24.222 24.62 23.879 24.746 23.571 C 24.872 23.264 25.061 23.028 25.313 22.864 C 25.565 22.7 25.847 22.618 26.158 22.618 C 26.386 22.618 26.589 22.666 26.767 22.762 C 26.946 22.859 27.091 22.984 27.202 23.138 L 27.202 21.264 L 27.84 21.264 L 27.84 26.489 L 27.245 26.489 Z M 25.217 24.6 C 25.217 25.085 25.319 25.447 25.523 25.687 C 25.728 25.927 25.969 26.047 26.247 26.047 C 26.527 26.047 26.765 25.932 26.961 25.703 C 27.158 25.474 27.256 25.124 27.256 24.653 C 27.256 24.135 27.156 23.755 26.956 23.513 C 26.757 23.27 26.511 23.149 26.218 23.149 C 25.933 23.149 25.695 23.265 25.504 23.498 C 25.313 23.731 25.217 24.099 25.217 24.6 Z M 31.44 25.27 L 32.103 25.352 C 31.998 25.739 31.805 26.04 31.522 26.254 C 31.239 26.468 30.878 26.575 30.439 26.575 C 29.885 26.575 29.446 26.404 29.122 26.063 C 28.797 25.722 28.635 25.244 28.635 24.628 C 28.635 23.991 28.799 23.497 29.127 23.146 C 29.455 22.794 29.88 22.618 30.403 22.618 C 30.909 22.618 31.322 22.79 31.643 23.135 C 31.964 23.48 32.125 23.964 32.125 24.589 C 32.125 24.627 32.124 24.684 32.121 24.76 L 29.298 24.76 C 29.322 25.176 29.44 25.495 29.651 25.716 C 29.862 25.937 30.126 26.047 30.442 26.047 C 30.677 26.047 30.878 25.985 31.045 25.862 C 31.211 25.738 31.343 25.541 31.44 25.27 Z M 29.334 24.233 L 31.447 24.233 C 31.419 23.914 31.338 23.675 31.205 23.516 C 31.001 23.269 30.736 23.146 30.41 23.146 C 30.115 23.146 29.868 23.244 29.667 23.441 C 29.466 23.638 29.355 23.902 29.334 24.233 Z'
);
glyphs.setAttribute('fill', 'white');
badgeInner.appendChild(rect);
badgeInner.appendChild(glyphs);
badge.appendChild(badgeInner);
try { badge.style.zIndex = '1'; } catch (_) { }
// Unified container: wrap (translation, position = rectangle center), wrapInner (click scale + rotation)
const wrap = document.createElement('div');
wrap.style.position = 'absolute';
wrap.style.left = '0';
wrap.style.top = '0';
wrap.style.transformOrigin = '0 0';
wrap.style.willChange = 'transform';
const wrapInner = document.createElement('div');
wrapInner.style.transformOrigin = '0 0';
// Pivot sits at rectangle center (relative to tip). We use transformOrigin on pivot
// to rotate/orbit around the rectangle center while keeping wrap translating the tip.
const pivot = document.createElement('div');
pivot.style.position = 'absolute';
pivot.style.left = '0px';
pivot.style.top = '0px';
pivot.style.transformOrigin = '0 0'; // we'll set a numeric origin later once RCX/RCY known
// Paint order: put badge first, then arrow so arrow renders above rectangle
pivot.appendChild(badge);
pivot.appendChild(arrow);
wrapInner.appendChild(pivot);
wrap.appendChild(wrapInner);
root.appendChild(wrap);
// Hide briefly on first install to avoid initial flicker during attach/screenshot
try {
root.style.visibility = 'hidden';
setTimeout(() => { try { root.style.visibility = 'visible'; } catch (_) { } }, 150);
} catch (_) { }
// Initial state and transforms
const state = {
root, wrap, wrapInner, pivot, arrow, arrowInner, badge,
styleEl: null,
wrapX: Math.round(x),
wrapY: Math.round(y),
aAnim: null,
bAnim: null,
caAnim: null,
cbAnim: null,
cssActiveA: false,
cssActiveB: false,
lastMoveAt: 0,
lastAx: Math.round(x),
lastAy: Math.round(y),
lastDur: 0,
cancelLog: [],
ignoreHoverUntil: 0,
curveFlip: false,
};
// Initial transforms: wrap at tip (0,0), arrow and badge offset relative to tip
const RECT_CX = 10.82 + 25.686 / 2;
const RECT_CY = 18.564 + 10.691 / 2;
// Badge top-left relative to tip
const BADGE_TLX = (BADGE_OFF_X - TIP_X);
const BADGE_TLY = (BADGE_OFF_Y - TIP_Y);
// Rectangle center relative to tip
const RCX = BADGE_TLX + RECT_CX;
const RCY = BADGE_TLY + RECT_CY;
wrap.style.transform = 'translate3d(' + state.wrapX + 'px,' + state.wrapY + 'px,0)';
// Arrow so its tip is at (0,0)
arrow.style.transform = 'translate3d(' + (-TIP_X) + 'px,' + (-TIP_Y) + 'px,0)';
// Badge positioned by its top-left offset from tip
badge.style.transform = 'translate3d(' + BADGE_TLX + 'px,' + BADGE_TLY + 'px,0)';
// Rotate wrapInner around rectangle center (relative to tip)
try {
wrapInner.style.transformOrigin = RCX + 'px ' + RCY + 'px';
pivot.style.transformOrigin = RCX + 'px ' + RCY + 'px';
} catch (_) { }
// Create style element for dynamic CSS keyframes
try {
const st = document.createElement('style');
st.type = 'text/css';
st.id = '__vc_css_kf';
(document.head || document.documentElement || root).appendChild(st);
state.styleEl = st;
} catch (_) { }
arrow.style.willChange = 'transform';
badge.style.willChange = 'transform';
arrow.style.transformOrigin = '0 0';
badge.style.transformOrigin = '0 0';
// Motion configuration (CSS-only animations)
const MOTION = {
min_dist: 300,
max_dist: 1000,
min_ms: 600,
max_ms: 2000,
easing: 'cubic-bezier(.25,.1,.25,1)', // soft ease-out
arrowScale: 1.0,
badgeScale: 1.02,
badgeDelay: 35,
cssDurationMs: 0, // when >0, force CSS duration (diagnostic override)
honorReducedMotion: false,
curveFactor: 0.25, // curved path
curveMaxPx: 70,
curveAlternate: true,
rotateMaxDeg: 28,
arrowTilt: 0.5,
badgeTilt: 0.3,
overshootDeg: 10,
arrowOvershootScale: 1.0,
badgeOvershootScale: 0.7,
overshootAt: 0.92,
orbitMode: 'quad', // 'quad' | 'normal' | 'pivot' | 'none'
orbitBiasDown: 2.0, // multiplier for downward orbit
orbitBasePx: 16, // max orbit radius at full-screen distance
orbitMinPx: 100, // min distance (px) before orbit engages
backBegin: 0.40 // when to start rotating back (0..1)
};
function dist(x0, y0, x1, y1) {
const dx = x1 - x0, dy = y1 - y0;
return Math.hypot(dx, dy);
}
function durationForDistance(d) {
// pull values and provide sensible defaults (snappy by default)
let minDist = Number(MOTION.min_dist ?? 0);
let maxDist = Number(MOTION.max_dist ?? (minDist + 1)); // avoid zero range
let minMs = Number(MOTION.min_ms ?? 100);
let maxMs = Number(MOTION.max_ms ?? 300);
// If the user accidentally supplied reversed distances, fix by swapping
if (minDist > maxDist) {
[minDist, maxDist] = [maxDist, minDist];
[minMs, maxMs] = [maxMs, minMs];
}
// If distances are equal after defaults, treat as step
if (minDist === maxDist) return d <= minDist ? minMs : maxMs;
// normalized t in [0,1]
const t = Math.max(0, Math.min(1, (d - minDist) / (maxDist - minDist)));
// linear interpolation
return Math.round(minMs + t * (maxMs - minMs));
}
function commit(wx, wy, _bx, _by) {
state.wrapX = wx; state.wrapY = wy;
}
// Ensure elements use their currently computed transform as the inline baseline
function pinCurrent(el) {
try {
const cs = getComputedStyle(el);
const t = cs && cs.transform;
if (t && t !== 'none') {
// Set inline to the current computed transform to avoid visual jumps on cancel
el.style.transform = t;
}
} catch (e) { }
}
// Helper: apply CSS transition from current to target transform
function cssAnim(el, which, curX, curY, nextX, nextY, durMs, easing, delayMs) {
try {
el.style.transition = 'none';
el.style.transform = 'translate3d(' + Math.round(curX) + 'px,' + Math.round(curY) + 'px,0)';
void el.offsetWidth; // reflow
el.style.transition = 'transform ' + durMs + 'ms ' + easing + (delayMs > 0 ? (' ' + (delayMs | 0) + 'ms') : '');
el.style.transform = 'translate3d(' + Math.round(nextX) + 'px,' + Math.round(nextY) + 'px,0)';
if (which === 'a') state.cssActiveA = true; else if (which === 'b') state.cssActiveB = true;
const onEnd = () => { if (which === 'a') state.cssActiveA = false; else state.cssActiveB = false; el.removeEventListener('transitionend', onEnd); };
el.addEventListener('transitionend', onEnd);
} catch (e) { warn('cssAnim error', e); }
}
// Helpers to inject and run CSS keyframes
function addKeyframes(name, cssText) {
try {
if (!state.styleEl) return false;
state.styleEl.textContent += "\n@keyframes " + name + " {\n" + cssText + "\n}\n";
return true;
} catch (e) { warn('addKeyframes error', e); return false; }
}
function playKeyframes(el, name, dur, easing, delay) {
try {
el.style.animation = 'none';
void el.offsetWidth; // reflow
el.style.animation = name + ' ' + dur + 'ms ' + easing + ' ' + ((delay | 0)) + 'ms forwards';
} catch (e) { warn('playKeyframes error', e); }
}
function moveTo(nx, ny, opts) {
const o = Object.assign({}, MOTION, opts || {});
const wx1 = Math.round(nx), wy1 = Math.round(ny);
const wx0 = state.wrapX, wy0 = state.wrapY;
const now = (window.performance && performance.now) ? performance.now() : Date.now();
const sincePrev = now - (state.lastMoveAt || 0);
const coalesceMs = 80;
const noOp = (Math.abs(wx1 - wx0) < 0.5) && (Math.abs(wy1 - wy0) < 0.5);
if (noOp) { log('moveTo no-op', { wx1, wy1 }); return 0; }
const d = dist(wx0, wy0, wx1, wy1);
// For tiny moves, snap without animation to avoid visible twitch
if (d < 1.5) {
try { state.aAnim && state.aAnim.cancel(); } catch (e) { }
try { state.bAnim && state.bAnim.cancel(); } catch (e) { }
wrap.style.transform = 'translate3d(' + wx1 + 'px,' + wy1 + 'px,0)';
try { arrowInner.style.transform = 'translate(0px,0px) rotate(0deg)'; } catch (_) { }
commit(wx1, wy1, 0, 0);
state.lastMoveAt = now; state.lastAx = wx1; state.lastAy = wy1;
return 0;
}
const base = durationForDistance(d);
let aDur = Math.round(base * o.arrowScale);
let bDur = 0; const bDel = 0; // unified motion
log('moveTo', { from: { x: wx0, y: wy0 }, to: { x: wx1, y: wy1 }, d, base, aDur, engine: o.engine, easing: o.easing, waapi: (typeof wrap.animate === 'function'), sincePrev });
// During programmatic movement, suppress hover dimming so synthetic mousemove doesn't dim the cursor
try {
const totalPlan = Math.max(aDur, (bDel | 0) + bDur);
state.ignoreHoverUntil = Math.max(state.ignoreHoverUntil, now + totalPlan + 40);
hoverClearUntil = Math.max(hoverClearUntil, now + totalPlan + 40);
} catch (_) { }
const dx = wx1 - wx0, dy = wy1 - wy0;
const distNorm = Math.min(1, d / 80);
const dir = dx >= 0 ? 1 : -1; // rotation only depends on horizontal direction
const tiltBase = o.rotateMaxDeg * distNorm;
const bRotTarget = dir * tiltBase * o.badgeTilt;
// Pin current visual state.
pinCurrent(wrap);
// Orbit midpoints based on selected orbit mode
let midRot = 0;
const enableOrbit = d >= (o.orbitMinPx || 150);
const orbitScale = enableOrbit ? Math.max(0, Math.min(0.6, (d - 100) * 0.0006)) : 0; // 0..0.6 (over 1000)
// Quadrant-based poses provided by user
if (dx < 0 && dy > 0) {
midRot = Math.round(-107 * orbitScale);
}
else if (dx > 0 && dy > 0) {
midRot = Math.round(153 * orbitScale);
}
else if (dx > 0 && dy < 0) {
midRot = Math.round(-107 * orbitScale);
}
else { midRot = 0; }
log('orbit', { midRot, orbitScale });
const bTilt = Math.round(bRotTarget);
// CSS-only movement and rotation
const dur = (o.cssDurationMs && o.cssDurationMs > 0) ? (o.cssDurationMs | 0) : aDur;
const retarget = sincePrev <= coalesceMs;
if (retarget && (state.cssActiveA || state.cssActiveB)) {
log('css retarget', { dur, sincePrev, cssActiveA: state.cssActiveA, cssActiveB: state.cssActiveB });
wrap.style.transform = 'translate3d(' + wx1 + 'px,' + wy1 + 'px,0)';
} else {
log('css-mode begin', { dur, easing: o.easing });
cssAnim(wrap, 'a', wx0, wy0, wx1, wy1, dur, o.easing, 0);
}
// CSS-only orbit using injected keyframes on arrowInner/pivot
try {
// Unique names per run
state.seq = (state.seq | 0) + 1;
const anArrow = '__vc_arrow_' + state.seq;
const anPivot = '__vc_pivot_' + state.seq;
const baseA = 'translate3d(' + (-TIP_X) + 'px,' + (-TIP_Y) + 'px,0) ';
// Quadrant-based transform-origin and rotation (CSS path)
// Default to rectangle center for non-quad modes
let ORI_X = 36, ORI_Y = 31;
let cssDeg = midRot;
const qScale = orbitScale; // 0..1 based on distance
if (dx <= 0 && dy <= 0) {
// top left (no change to arrow orbit)
ORI_X = 0; ORI_Y = 0; cssDeg = Math.round(0 * qScale);
} else if (dx <= 0 && dy > 0) {
// bottom left
ORI_X = 17; ORI_Y = 29; cssDeg = Math.round(-90 * qScale);
} else if (dx > 0 && dy <= 0) {
// top right
ORI_X = 33; ORI_Y = 40; cssDeg = Math.round(90 * qScale);
} else {
// bottom right (use -179deg to force anticlockwise)
//ORI_X = 32; ORI_Y = 28; cssDeg = Math.round(-179 * qScale);
// Use top right as bottom right looks weird
ORI_X = 33; ORI_Y = 40; cssDeg = Math.round(90 * qScale);
}
// Arrow keyframes: rotate to quadrant target then return gradually.
// Reset transform-origin to 0 0 at end so clicks look correct.
const arrowKF =
'0%{transform:' + baseA + 'rotate(0deg);transform-origin:' + ORI_X + 'px ' + ORI_Y + 'px}\n' +
'10%{transform:' + baseA + 'rotate(' + cssDeg + 'deg);transform-origin:' + ORI_X + 'px ' + ORI_Y + 'px}\n' +
'99%{transform:' + baseA + 'rotate(0deg);transform-origin:' + ORI_X + 'px ' + ORI_Y + 'px}\n' +
'100%{transform:' + baseA + 'rotate(0deg);transform-origin:0px 0px}';
const pKFcss =
'0%{transform:rotate(0deg)}\n' +
'50%{transform:rotate(' + bTilt + 'deg)}\n' +
'100%{transform:rotate(0deg)}';
addKeyframes(anArrow, arrowKF);
addKeyframes(anPivot, pKFcss);
playKeyframes(arrow, anArrow, dur, o.easing, 0);
if (bTilt !== 0) playKeyframes(pivot, anPivot, dur, o.easing, Math.round(dur*0.08));
} catch (_) { }
commit(wx1, wy1, 0, 0);
state.lastMoveAt = now; state.lastAx = wx1; state.lastAy = wy1; state.lastDur = dur;
if (window.__vc && window.__vc._overlay && window.__vc._overlayUpdate) window.__vc._overlayUpdate();
return dur;
}
// --- Hover-to-dim (distance to tip) ---
root.style.opacity = '1';
root.style.transition = 'opacity 160ms ease-out';
// Avoid hover dimming right after install to reduce perceived flicker
try {
const nowTS = (window.performance && performance.now) ? performance.now() : Date.now();
state.ignoreHoverUntil = nowTS + 600;
} catch (_) { }
const HOVER = { opacity: 0.2, offset: 20, radius: 55, enabled: true };
let hoverClearUntil = 0;
let _mx = 0, _my = 0, _rafHover = 0, _dimmed = false;
function hoverTick() {
_rafHover = 0;
const now = (window.performance && performance.now) ? performance.now() : Date.now();
if (now < state.ignoreHoverUntil || now < hoverClearUntil) {
// Ignore hover updates during synthetic/programmatic moves
if (_dimmed) { _dimmed = false; root.style.opacity = '1'; }
return;
}
const tipX = state.wrapX + HOVER.offset;
const tipY = state.wrapY + HOVER.offset;
const dx = _mx - tipX, dy = _my - tipY;
const over = (dx * dx + dy * dy) <= (HOVER.radius * HOVER.radius);
const shouldDim = HOVER.enabled && over;
if (shouldDim !== _dimmed) {
_dimmed = shouldDim;
root.style.opacity = shouldDim ? String(HOVER.opacity) : '1';
}
}
function scheduleHover(ev) {
// Ignore synthetic/injected events and updates during suppression window
try { if (ev && ev.isTrusted === false) return; } catch (_) { }
const t = (window.performance && performance.now) ? performance.now() : Date.now();
if (t < state.ignoreHoverUntil || t < hoverClearUntil) return;
_mx = ev.clientX; _my = ev.clientY;
if (!_rafHover) _rafHover = requestAnimationFrame(hoverTick);
}
window.addEventListener('mousemove', scheduleHover, { passive: true });
window.addEventListener('mouseleave', function () {
if (_dimmed) { _dimmed = false; root.style.opacity = '1'; }
}, { passive: true });
// Public API
window.__vc = {
moveTo: moveTo, // preferred; returns ms duration
update: function (nx, ny) { // backwards compat; returns ms duration
return moveTo(nx, ny);
},
// Snap instantly without WAAPI (host-driven stepping can use this)
snapTo: function (nx, ny) {
try { state.aAnim && state.aAnim.cancel(); } catch (e) { }
try { state.bAnim && state.bAnim.cancel(); } catch (e) { }
try { state.raAnim && state.raAnim.cancel(); } catch (e) { }
wrap.style.transform = 'translate3d(' + Math.round(nx) + 'px,' + Math.round(ny) + 'px,0)';
try { arrowInner.style.transform = 'translate(0px,0px) rotate(0deg)'; } catch (_) { }
commit(Math.round(nx), Math.round(ny), 0, 0);
return true;
},
// Click pulse animation: scale wrapInner + ripple at tip (CSS-only)
clickPulse: function (opts) {
const dur = (opts && opts.duration) || 550;
// Suppress hover dimming during click pulse
try {
const nowTS = (window.performance && performance.now) ? performance.now() : Date.now();
const until = nowTS + (dur + 120);
state.ignoreHoverUntil = Math.max(state.ignoreHoverUntil, until);
hoverClearUntil = Math.max(hoverClearUntil, until);
// proactively clear any dim
try { root.style.opacity = '1'; } catch (_) { }
} catch (e) { }
// Scale on wrapInner from top-left via CSS keyframes
try {
state.seq = (state.seq | 0) + 1;
const aName = '__vc_click_scale_' + state.seq;
addKeyframes(aName,
'0%{transform:scale(1); transform-origin:-5px -5px;}\n' +
'48%{transform:scale(0.8); transform-origin:-5px -5px;}\n' +
'52%{transform:scale(0.8); transform-origin:-5px -5px;}\n' +
'99%{transform:scale(1); transform-origin:-5px -5px;}\n' +
'100%{transform-origin:0px 0px;}');
playKeyframes(state.wrapInner, aName, dur, 'cubic-bezier(0.16, 1, 0.3, 1)', 0);
} catch (_) { }
// Transient ring ripple near the tip for visibility
try {
const ring = document.createElement('div');
ring.className = '__vc_click_ring';
const tipX = state.wrapX; // tip coincides with wrap origin
const tipY = state.wrapY;
const sz = 18; // ring base size
Object.assign(ring.style, {
position: 'absolute',
left: (tipX - sz / 2) + 'px',
top: (tipY - sz / 2) + 'px',
width: sz + 'px',
height: sz + 'px',
borderRadius: '999px',
border: '2px solid rgba(255,255,255,0.95)',
boxShadow: '0 0 0 2px rgba(0,0,0,0.15)',
opacity: '0.9',
pointerEvents: 'none',
transform: 'scale(0.6)',
transformOrigin: 'center center',
willChange: 'transform, opacity',
zIndex: '2147483647'
});
state.root.appendChild(ring);
state.seq = (state.seq | 0) + 1;
const rName = '__vc_click_ring_' + state.seq;
addKeyframes(rName,
'0%{transform:scale(0.6);opacity:0.9}\n' +
'100%{transform:scale(1.8);opacity:0}');
playKeyframes(ring, rName, 480, 'cubic-bezier(0.22, 1, 0.36, 1)', 0);
ring.addEventListener('animationend', function onEnd() { try { ring.remove(); } catch (e) { } });
} catch (e) { }
return dur * 2 + 80; // approximate total
},
// Return an estimated remaining time (ms) for any in-flight animations
getSettleMs: function () {
try {
const nowTS = (window.performance && performance.now) ? performance.now() : Date.now();
const elapsed = Math.max(0, nowTS - (state.lastMoveAt || 0));
const left = Math.max(0, (state.lastDur || 0) - elapsed);
return Math.ceil(left);
} catch (_) { return 0; }
},
setSize: function (arrowPx, badgePx) {
if (arrowPx) arrow.style.width = arrowPx + 'px';
if (badgePx) badge.style.width = badgePx + 'px';
},
setMotion: function (p) { Object.assign(MOTION, p || {}); if (window.__vc && window.__vc._overlay && window.__vc._overlayUpdate) window.__vc._overlayUpdate(); },
dump: function () {
try {
const tf = (el) => el ? getComputedStyle(el).transform : null;
console.debug('[VC/dump]', {
orbitMode: MOTION.orbitMode, orbitMinPx: MOTION.orbitMinPx,
wrap: tf(state.wrap), wrapInner: tf(state.wrapInner), pivot: tf(state.pivot),
arrow: tf(state.arrow), badge: tf(state.badge),
wrapPos: { x: state.wrapX, y: state.wrapY }, lastDur: state.lastDur
});
return true;
} catch (e) { console.warn('[VC/dump] error', e); return false; }
},
setHover: function (p) { Object.assign(HOVER, p || {}); },
setOrbitMode: function (m) { if (m) MOTION.orbitMode = String(m); return MOTION.orbitMode; },
getOrbitMode: function () { return MOTION.orbitMode; },
// debugOrbit removed (WAAPI-free)
setDebug: function (flag) {
try {
if (!flag) {
if (window.__vc && window.__vc._overlay) { window.__vc._overlay.remove(); window.__vc._overlay = null; }
return true;
}
if (!window.__vc._overlay) {
const el = document.createElement('div');
el.style.cssText = 'position:fixed;bottom:8px;left:8px;background:rgba(0,0,0,0.6);color:#fff;padding:6px 8px;border-radius:6px;font:12px/1.4 -apple-system,Segoe UI,Arial;z-index:2147483647;pointer-events:none;white-space:pre;';
window.__vc._overlay = el;
document.body.appendChild(el);
}
window.__vc._overlayUpdate = function () {
try {
const txt = 'orbit: ' + (MOTION.orbitMode || '') + '\n' +
'target: (' + state.arrowX + ',' + state.arrowY + ')\n' +
'current: cssA:' + (state.cssActiveA ? 1 : 0) + ' cssB:' + (state.cssActiveB ? 1 : 0) + '\n' +
'last dur: ' + (state.lastDur || 0) + 'ms\n' +
'cancels: ' + state.cancelLog.length + '\n' +
'lastMoveAt: +' + Math.round(state.lastMoveAt || 0) + 'ms';
window.__vc._overlay.textContent = txt;
} catch (e) { }
};
window.__vc._overlayUpdate();
return true;
} catch (e) { return false; }
},
// Quick random move helpers for testing
testMove: function (dx, dy, opts) {
try {
const s = window.__vc && window.__vc._s; if (!s) return 0;
let x = s.wrapX + (typeof dx === 'number' ? dx : 0);
let y = s.wrapY + (typeof dy === 'number' ? dy : 0);
if (typeof dx !== 'number' || typeof dy !== 'number') {
const W = (window.innerWidth || 1024), H = (window.innerHeight || 768);
x = Math.max(20, Math.min(W - 20, Math.round(Math.random() * W)));
y = Math.max(20, Math.min(H - 20, Math.round(Math.random() * H)));
}
return window.__vc.moveTo(x, y, opts || null);
} catch (e) { console.warn('[VC/testMove] error', e); return 0; }
},
randomWalk: function (count, opts) {
count = (count | 0) || 4;
let i = 0;
function step() {
if (i++ >= count) return true;
const ms = window.__vc.testMove(null, null, opts || null) || 800;
setTimeout(step, Math.max(80, ms + 120));
}
step();
return true;
},
destroy: function () {
// During programmatic movement, suppress hover dimming as CDP will fire mousemove events.
// Use remaining animation time if available, otherwise a small fixed window.
try {
const nowTS = (window.performance && performance.now) ? performance.now() : Date.now();
const rem = (window.__vc && typeof window.__vc.getSettleMs === 'function') ? window.__vc.getSettleMs() : 0;
const total = Math.max(160, rem + 40);
state.ignoreHoverUntil = nowTS + total;
} catch (e) { }
window.removeEventListener('mousemove', scheduleHover);
if (root && root.parentNode) root.parentNode.removeChild(root);
window.__vc = null;
},
__bootstrap: false,
__version: 11,
_s: state
};
// Go to initial position
window.__vc.moveTo(x, y);
} else {
// Already installed; just move to the new position
window.__vc.moveTo(x, y);
}
return (typeof window.__vc === 'object') ? 'ok' : 'missing';
} catch (e) {
try { console.error('[VC] inject error', e); } catch (_) { }
return 'error:' + (e && (e.message || e))
}
}
try { Object.defineProperty(window, '__vcInstall', { value: __vcInstall, configurable: true, writable: true }); }
catch (_) { window.__vcInstall = __vcInstall; }
})();
+49
View File
@@ -0,0 +1,49 @@
pub mod assets;
pub mod config;
pub mod global;
pub mod hooks;
pub mod manager;
pub mod page;
pub mod tools;
pub use config::BrowserConfig;
pub use config::ViewportConfig;
pub use config::WaitStrategy;
pub use manager::BrowserManager;
pub use page::Page;
pub use page::ScreenshotMode;
pub use page::ScreenshotRegion;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BrowserError {
#[error("Browser not initialized")]
NotInitialized,
#[error("Page not loaded")]
PageNotLoaded,
#[error("CDP error: {0}")]
CdpError(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Screenshot failed: {0}")]
ScreenshotError(String),
#[error("Invalid configuration: {0}")]
ConfigError(String),
#[error("Asset storage error: {0}")]
AssetError(String),
}
impl From<chromiumoxide::error::CdpError> for BrowserError {
fn from(e: chromiumoxide::error::CdpError) -> Self {
BrowserError::CdpError(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, BrowserError>;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
use crate::Result;
use crate::assets::AssetManager;
use crate::assets::ImageRef;
use crate::config::WaitStrategy;
use crate::manager::BrowserManager;
use crate::page::ScreenshotMode;
use crate::page::ScreenshotRegion;
use crate::page::SetViewportParams;
use serde::Deserialize;
use serde::Serialize;
use std::sync::Arc;
pub struct BrowserTools {
manager: Arc<BrowserManager>,
asset_manager: Arc<AssetManager>,
}
impl BrowserTools {
pub fn new(manager: Arc<BrowserManager>, asset_manager: Arc<AssetManager>) -> Self {
Self {
manager,
asset_manager,
}
}
pub async fn handle_tool_call(&self, call: BrowserToolCall) -> Result<BrowserToolResult> {
match call {
BrowserToolCall::Goto { url, wait } => {
let page = self.manager.get_or_create_page().await?;
let result = page.goto(&url, wait).await?;
Ok(BrowserToolResult::Goto(result))
}
BrowserToolCall::Screenshot {
mode,
segments_max,
region,
inject_js,
format: _,
} => {
let page = self.manager.get_or_create_page().await?;
if let Some(script) = inject_js {
page.inject_js(&script).await?;
}
let screenshot_mode = match mode.as_deref() {
Some("full_page") => ScreenshotMode::FullPage { segments_max },
Some("viewport") | None => ScreenshotMode::Viewport,
Some(_) => {
if let Some(r) = region {
ScreenshotMode::Region(ScreenshotRegion {
x: r.x,
y: r.y,
width: r.width,
height: r.height,
})
} else {
ScreenshotMode::Viewport
}
}
};
let screenshots = page.screenshot(screenshot_mode).await?;
let ttl_ms = 300000;
let images = self
.asset_manager
.store_screenshots(screenshots, ttl_ms)
.await?;
Ok(BrowserToolResult::Screenshot(ScreenshotResult { images }))
}
BrowserToolCall::SetViewport {
width,
height,
device_scale_factor,
mobile,
} => {
let page = self.manager.get_or_create_page().await?;
let result = page
.set_viewport(SetViewportParams {
width,
height,
device_scale_factor,
mobile,
})
.await?;
// Update manager config to reflect the new viewport (no auto-resyncs)
let dpr = device_scale_factor.unwrap_or(1.0);
let mob = mobile.unwrap_or(false);
let _ = self
.manager
.update_config(|cfg| {
cfg.viewport.width = width;
cfg.viewport.height = height;
cfg.viewport.device_scale_factor = dpr;
cfg.viewport.mobile = mob;
})
.await;
Ok(BrowserToolResult::SetViewport(result))
}
BrowserToolCall::Close { what } => match what.as_deref() {
Some("browser") => {
self.manager.stop().await?;
Ok(BrowserToolResult::Close(CloseResult {
closed: "browser".to_string(),
}))
}
Some("page") | None => {
self.manager.close_page().await?;
Ok(BrowserToolResult::Close(CloseResult {
closed: "page".to_string(),
}))
}
Some(other) => Err(crate::BrowserError::ConfigError(format!(
"Unknown close target: {other}"
))),
},
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "tool", rename_all = "snake_case")]
pub enum BrowserToolCall {
#[serde(rename = "browser.goto")]
Goto {
url: String,
wait: Option<WaitStrategy>,
},
#[serde(rename = "browser.screenshot")]
Screenshot {
mode: Option<String>,
segments_max: Option<usize>,
region: Option<RegionParams>,
inject_js: Option<String>,
format: Option<String>,
},
#[serde(rename = "browser.setViewport")]
SetViewport {
width: u32,
height: u32,
device_scale_factor: Option<f64>,
mobile: Option<bool>,
},
#[serde(rename = "browser.close")]
Close { what: Option<String> },
}
#[derive(Debug, Deserialize)]
pub struct RegionParams {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum BrowserToolResult {
Goto(crate::page::GotoResult),
Screenshot(ScreenshotResult),
SetViewport(crate::page::ViewportResult),
Close(CloseResult),
}
#[derive(Debug, Serialize)]
pub struct ScreenshotResult {
pub images: Vec<ImageRef>,
}
#[derive(Debug, Serialize)]
pub struct CloseResult {
pub closed: String,
}
+8
View File
@@ -0,0 +1,8 @@
pub mod browser_tools;
pub mod schema;
pub use browser_tools::BrowserToolCall;
pub use browser_tools::BrowserToolResult;
pub use browser_tools::BrowserTools;
pub use schema::BrowserToolSchema;
pub use schema::get_browser_tools_schema;
+127
View File
@@ -0,0 +1,127 @@
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserToolSchema {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
pub fn get_browser_tools_schema() -> Vec<BrowserToolSchema> {
vec![
BrowserToolSchema {
name: "browser.goto".to_string(),
description: "Navigate to a URL and wait for the page to load".to_string(),
parameters: json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to navigate to"
},
"wait": {
"oneOf": [
{
"type": "string",
"enum": ["domcontentloaded", "networkidle", "networkidle0", "networkidle2", "load"],
"description": "Wait for a specific event"
},
{
"type": "object",
"properties": {
"delay_ms": {
"type": "number",
"description": "Wait for a specific delay in milliseconds"
}
},
"required": ["delay_ms"]
}
],
"description": "Wait strategy for page load"
}
},
"required": ["url"]
}),
},
BrowserToolSchema {
name: "browser.screenshot".to_string(),
description: "Take a screenshot of the current page".to_string(),
parameters: json!({
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["viewport", "full_page"],
"description": "Screenshot mode (default: viewport)"
},
"segments_max": {
"type": "number",
"description": "Maximum number of segments for full_page mode (default: 8)"
},
"region": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"width": { "type": "number" },
"height": { "type": "number" }
},
"required": ["x", "y", "width", "height"],
"description": "Optional region to capture"
},
"inject_js": {
"type": "string",
"description": "JavaScript to inject before screenshot"
},
"format": {
"type": "string",
"enum": ["png", "webp"],
"description": "Image format (default: png)"
}
}
}),
},
BrowserToolSchema {
name: "browser.setViewport".to_string(),
description: "Set the browser viewport size".to_string(),
parameters: json!({
"type": "object",
"properties": {
"width": {
"type": "number",
"description": "Viewport width in pixels"
},
"height": {
"type": "number",
"description": "Viewport height in pixels"
},
"device_scale_factor": {
"type": "number",
"description": "Device scale factor (default: 1.0)"
},
"mobile": {
"type": "boolean",
"description": "Enable mobile mode (default: false)"
}
},
"required": ["width", "height"]
}),
},
BrowserToolSchema {
name: "browser.close".to_string(),
description: "Close the page or browser".to_string(),
parameters: json!({
"type": "object",
"properties": {
"what": {
"type": "string",
"enum": ["page", "browser"],
"description": "What to close (default: page)"
}
}
}),
},
]
}
+22
View File
@@ -0,0 +1,22 @@
[package]
edition = "2024"
name = "code-chatgpt"
version = { workspace = true }
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
code-common = { workspace = true, features = ["cli"] }
code-core = { workspace = true }
code-protocol = { workspace = true }
code-app-server-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
code-git-apply = { path = "../git-apply" }
[dev-dependencies]
tempfile = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# ChatGPT
This crate pertains to first party ChatGPT APIs and products such as Codex agent.
This crate should be primarily built and maintained by OpenAI employees. Please reach out to a maintainer before making an external contribution.
+79
View File
@@ -0,0 +1,79 @@
use std::path::PathBuf;
use clap::Parser;
use code_common::CliConfigOverrides;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
use crate::get_task::GetTaskResponse;
use crate::get_task::OutputItem;
use crate::get_task::PrOutputItem;
use crate::get_task::get_task;
/// Applies the latest diff from a Codex agent task.
#[derive(Debug, Parser)]
pub struct ApplyCommand {
pub task_id: String,
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
}
pub async fn run_apply_command(
apply_cli: ApplyCommand,
cwd: Option<PathBuf>,
) -> anyhow::Result<()> {
let config = Config::load_with_cli_overrides(
apply_cli
.config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?,
ConfigOverrides::default(),
)?;
init_chatgpt_token_from_auth(&config.code_home, &config.responses_originator_header).await?;
let task_response = get_task(&config, apply_cli.task_id).await?;
apply_diff_from_task(task_response, cwd).await
}
pub async fn apply_diff_from_task(
task_response: GetTaskResponse,
cwd: Option<PathBuf>,
) -> anyhow::Result<()> {
let diff_turn = match task_response.current_diff_task_turn {
Some(turn) => turn,
None => anyhow::bail!("No diff turn found"),
};
let output_diff = diff_turn.output_items.iter().find_map(|item| match item {
OutputItem::Pr(PrOutputItem { output_diff }) => Some(output_diff),
_ => None,
});
match output_diff {
Some(output_diff) => apply_diff(&output_diff.diff, cwd).await,
None => anyhow::bail!("No PR output item found"),
}
}
async fn apply_diff(diff: &str, cwd: Option<PathBuf>) -> anyhow::Result<()> {
let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()));
let req = code_git_apply::ApplyGitRequest {
cwd,
diff: diff.to_string(),
revert: false,
preflight: false,
};
let res = code_git_apply::apply_git_patch(&req)?;
if res.exit_code != 0 {
anyhow::bail!(
"Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}",
res.applied_paths.len(),
res.skipped_paths.len(),
res.conflicted_paths.len(),
res.stdout,
res.stderr
);
}
println!("Successfully applied diff");
Ok(())
}
+48
View File
@@ -0,0 +1,48 @@
use code_core::config::Config;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
use anyhow::Context;
use serde::de::DeserializeOwned;
/// Make a GET request to the ChatGPT backend API.
pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
config: &Config,
path: String,
) -> anyhow::Result<T> {
let chatgpt_base_url = &config.chatgpt_base_url;
init_chatgpt_token_from_auth(&config.code_home, &config.responses_originator_header).await?;
// Make direct HTTP request to ChatGPT backend API with the token
let client = code_core::http_client::build_http_client();
let url = format!("{chatgpt_base_url}{path}");
let token =
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
let account_id = token.account_id.ok_or_else(|| {
anyhow::anyhow!("ChatGPT account ID not available, please re-run `code login`")
});
let response = client
.get(&url)
.bearer_auth(&token.access_token)
.header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
.send()
.await
.context("Failed to send request")?;
if response.status().is_success() {
let result: T = response
.json()
.await
.context("Failed to parse JSON response")?;
Ok(result)
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Request failed with status {status}: {body}")
}
}
+32
View File
@@ -0,0 +1,32 @@
use code_core::CodexAuth;
use code_app_server_protocol::AuthMode;
use std::path::Path;
use std::sync::LazyLock;
use std::sync::RwLock;
use code_core::token_data::TokenData;
static CHATGPT_TOKEN: LazyLock<RwLock<Option<TokenData>>> = LazyLock::new(|| RwLock::new(None));
pub fn get_chatgpt_token_data() -> Option<TokenData> {
CHATGPT_TOKEN.read().ok()?.clone()
}
pub fn set_chatgpt_token_data(value: TokenData) {
if let Ok(mut guard) = CHATGPT_TOKEN.write() {
*guard = Some(value);
}
}
/// Initialize the ChatGPT token from auth.json file
pub async fn init_chatgpt_token_from_auth(
code_home: &Path,
originator: &str,
) -> std::io::Result<()> {
let auth = CodexAuth::from_code_home(code_home, AuthMode::ChatGPT, originator)?;
if let Some(auth) = auth {
let token_data = auth.get_token_data().await?;
set_chatgpt_token_data(token_data);
}
Ok(())
}
+40
View File
@@ -0,0 +1,40 @@
use code_core::config::Config;
use serde::Deserialize;
use crate::chatgpt_client::chatgpt_get_request;
#[derive(Debug, Deserialize)]
pub struct GetTaskResponse {
pub current_diff_task_turn: Option<AssistantTurn>,
}
// Only relevant fields for our extraction
#[derive(Debug, Deserialize)]
pub struct AssistantTurn {
pub output_items: Vec<OutputItem>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum OutputItem {
#[serde(rename = "pr")]
Pr(PrOutputItem),
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
pub struct PrOutputItem {
pub output_diff: OutputDiff,
}
#[derive(Debug, Deserialize)]
pub struct OutputDiff {
pub diff: String,
}
pub(crate) async fn get_task(config: &Config, task_id: String) -> anyhow::Result<GetTaskResponse> {
let path = format!("/wham/tasks/{task_id}");
chatgpt_get_request(config, path).await
}
+4
View File
@@ -0,0 +1,4 @@
pub mod apply_command;
mod chatgpt_client;
mod chatgpt_token;
pub mod get_task;
+3
View File
@@ -0,0 +1,3 @@
// Single integration test binary that aggregates all test modules.
// The submodules live in `tests/suite/`.
mod suite;
@@ -0,0 +1,188 @@
use code_chatgpt::apply_command::apply_diff_from_task;
use code_chatgpt::get_task::GetTaskResponse;
use std::path::Path;
use tempfile::TempDir;
use tokio::process::Command;
/// Creates a temporary git repository with initial commit
async fn create_temp_git_repo() -> anyhow::Result<TempDir> {
let temp_dir = TempDir::new()?;
let repo_path = temp_dir.path();
let envs = vec![
("GIT_CONFIG_GLOBAL", "/dev/null"),
("GIT_CONFIG_NOSYSTEM", "1"),
];
let output = Command::new("git")
.envs(envs.clone())
.args(["init"])
.current_dir(repo_path)
.output()
.await?;
if !output.status.success() {
anyhow::bail!(
"Failed to initialize git repo: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Command::new("git")
.envs(envs.clone())
.args(["config", "user.email", "[email protected]"])
.current_dir(repo_path)
.output()
.await?;
Command::new("git")
.envs(envs.clone())
.args(["config", "user.name", "Test User"])
.current_dir(repo_path)
.output()
.await?;
std::fs::write(repo_path.join("README.md"), "# Test Repo\n")?;
Command::new("git")
.envs(envs.clone())
.args(["add", "README.md"])
.current_dir(repo_path)
.output()
.await?;
let output = Command::new("git")
.envs(envs.clone())
.args(["commit", "-m", "Initial commit"])
.current_dir(repo_path)
.output()
.await?;
if !output.status.success() {
anyhow::bail!(
"Failed to create initial commit: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(temp_dir)
}
async fn mock_get_task_with_fixture() -> anyhow::Result<GetTaskResponse> {
let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/task_turn_fixture.json");
let fixture_content = std::fs::read_to_string(fixture_path)?;
let response: GetTaskResponse = serde_json::from_str(&fixture_content)?;
Ok(response)
}
#[tokio::test]
async fn test_apply_command_creates_fibonacci_file() {
let temp_repo = create_temp_git_repo()
.await
.expect("Failed to create temp git repo");
let repo_path = temp_repo.path();
let task_response = mock_get_task_with_fixture()
.await
.expect("Failed to load fixture");
apply_diff_from_task(task_response, Some(repo_path.to_path_buf()))
.await
.expect("Failed to apply diff from task");
// Assert that fibonacci.js was created in scripts/ directory
let fibonacci_path = repo_path.join("scripts/fibonacci.js");
assert!(fibonacci_path.exists(), "fibonacci.js was not created");
// Verify the file contents match expected
let contents = std::fs::read_to_string(&fibonacci_path).expect("Failed to read fibonacci.js");
assert!(
contents.contains("function fibonacci(n)"),
"fibonacci.js doesn't contain expected function"
);
assert!(
contents.contains("#!/usr/bin/env node"),
"fibonacci.js doesn't have shebang"
);
assert!(
contents.contains("module.exports = fibonacci;"),
"fibonacci.js doesn't export function"
);
// Verify file has correct number of lines (31 as specified in fixture)
let line_count = contents.lines().count();
assert_eq!(
line_count, 31,
"fibonacci.js should have 31 lines, got {line_count}",
);
}
#[tokio::test]
async fn test_apply_command_with_merge_conflicts() {
let temp_repo = create_temp_git_repo()
.await
.expect("Failed to create temp git repo");
let repo_path = temp_repo.path();
// Create conflicting fibonacci.js file first
let scripts_dir = repo_path.join("scripts");
std::fs::create_dir_all(&scripts_dir).expect("Failed to create scripts directory");
let conflicting_content = r#"#!/usr/bin/env node
// This is a different fibonacci implementation
function fib(num) {
if (num <= 1) return num;
return fib(num - 1) + fib(num - 2);
}
console.log("Running fibonacci...");
console.log(fib(10));
"#;
let fibonacci_path = scripts_dir.join("fibonacci.js");
std::fs::write(&fibonacci_path, conflicting_content).expect("Failed to write conflicting file");
Command::new("git")
.args(["add", "scripts/fibonacci.js"])
.current_dir(repo_path)
.output()
.await
.expect("Failed to add fibonacci.js");
Command::new("git")
.args(["commit", "-m", "Add conflicting fibonacci implementation"])
.current_dir(repo_path)
.output()
.await
.expect("Failed to commit conflicting file");
let original_dir = std::env::current_dir().expect("Failed to get current dir");
std::env::set_current_dir(repo_path).expect("Failed to change directory");
struct DirGuard(std::path::PathBuf);
impl Drop for DirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
let _guard = DirGuard(original_dir);
let task_response = mock_get_task_with_fixture()
.await
.expect("Failed to load fixture");
let apply_result = apply_diff_from_task(task_response, Some(repo_path.to_path_buf())).await;
assert!(
apply_result.is_err(),
"Expected apply to fail due to merge conflicts"
);
let contents = std::fs::read_to_string(&fibonacci_path).expect("Failed to read fibonacci.js");
assert!(
contents.contains("<<<<<<< HEAD")
|| contents.contains("=======")
|| contents.contains(">>>>>>> "),
"fibonacci.js should contain merge conflict markers, got: {contents}",
);
}
+2
View File
@@ -0,0 +1,2 @@
// Aggregates all former standalone integration tests as modules.
mod apply_command_e2e;
@@ -0,0 +1,65 @@
{
"current_diff_task_turn": {
"output_items": [
{
"type": "pr",
"pr_title": "Add fibonacci script",
"pr_message": "## Summary\n- add a basic Fibonacci script under `scripts/`\n\n## Testing\n- `node scripts/fibonacci.js 10`\n- `npm run lint` *(fails: next not found)*",
"output_diff": {
"type": "output_diff",
"repo_id": "/workspace/rddit-vercel",
"base_commit_sha": "1a2e9baf2ce2fdd0c126b47b1bcfd512de2a9f7b",
"diff": "diff --git a/scripts/fibonacci.js b/scripts/fibonacci.js\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6c9fdfdbf8669b7968936411050525b995d0a9a6\n--- /dev/null\n+++ b/scripts/fibonacci.js\n@@ -0,0 +1,31 @@\n+#!/usr/bin/env node\n+\n+function fibonacci(n) {\n+ if (n < 0) {\n+ throw new Error(\"n must be non-negative\");\n+ }\n+ let a = 0;\n+ let b = 1;\n+ for (let i = 0; i < n; i++) {\n+ const next = a + b;\n+ a = b;\n+ b = next;\n+ }\n+ return a;\n+}\n+\n+function printUsage() {\n+ console.log(\"Usage: node scripts/fibonacci.js <n>\");\n+}\n+\n+if (require.main === module) {\n+ const arg = process.argv[2];\n+ if (arg === undefined || isNaN(Number(arg))) {\n+ printUsage();\n+ process.exit(1);\n+ }\n+ const n = Number(arg);\n+ console.log(fibonacci(n));\n+}\n+\n+module.exports = fibonacci;\n",
"external_storage_diff": {
"file_id": "file_00000000114c61f786900f8c2130ace7",
"ttl": null
},
"files_modified": 1,
"lines_added": 31,
"lines_removed": 0,
"commit_message": "Add fibonacci script"
}
},
{
"type": "message",
"role": "assistant",
"content": [
{
"content_type": "text",
"text": "**Summary**\n\n- Created a command-line Fibonacci script that validates input and prints the result when executed with Node"
},
{
"content_type": "repo_file_citation",
"path": "scripts/fibonacci.js",
"line_range_start": 1,
"line_range_end": 31
},
{
"content_type": "text",
"text": "\n\n**Testing**\n\n- ❌ `npm run lint` (failed to run `next lint`)"
},
{
"content_type": "terminal_chunk_citation",
"terminal_chunk_id": "7dd543",
"line_range_start": 1,
"line_range_end": 5
},
{
"content_type": "text",
"text": "\n- ✅ `node scripts/fibonacci.js 10` produced “55”"
},
{
"content_type": "terminal_chunk_citation",
"terminal_chunk_id": "6ee559",
"line_range_start": 1,
"line_range_end": 3
},
{
"content_type": "text",
"text": "\n\nCodex couldn't run certain commands due to environment limitations. Consider configuring a setup script or internet access in your Codex environment to install dependencies."
}
]
}
]
}
}
+64
View File
@@ -0,0 +1,64 @@
[package]
edition = "2024"
name = "code-cli"
version = { workspace = true }
[[bin]]
name = "code"
path = "src/main.rs"
[lib]
name = "code_cli"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
clap_complete = { workspace = true }
code-app-server = { workspace = true }
code-arg0 = { workspace = true }
code-chatgpt = { workspace = true }
code-common = { workspace = true, features = ["cli"] }
code-core = { workspace = true }
code-exec = { workspace = true }
code-login = { workspace = true }
code-mcp-server = { workspace = true }
code-process-hardening = { workspace = true }
code-protocol = { workspace = true }
code-app-server-protocol = { workspace = true }
code-protocol-ts = { workspace = true }
code-responses-api-proxy = { workspace = true }
code-tui = { workspace = true }
code-version = { path = "../code-version" }
code-cloud-tasks = { workspace = true }
ctor = { workspace = true }
futures = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "json"] }
regex = "1"
tar = "0.4"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
zip = { version = "0.6", default-features = false, features = ["deflate"] }
tempfile = { workspace = true }
uuid = { version = "1", features = ["v4"] }
which = { workspace = true }
owo-colors = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true, features = ["derive"] }
supports-color = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
"signal",
] }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
[dev-dependencies]
assert_cmd = { workspace = true }
predicates = { workspace = true }
pretty_assertions = { workspace = true }
+96
View File
@@ -0,0 +1,96 @@
use anyhow::{Context, Result};
use regex::Regex;
use serde::Deserialize;
use serde_json::Value;
use std::fs;
use std::path::Path;
// Note: helper types for decoding packed keys were removed
// because this binary now parses structured logs instead.
fn parse_response_expected(path: &Path) -> Result<Vec<(u64, u64)>> {
// Returns vector of (out, seq) in the order they appear if we sort by out then seq
// We only consider events that carry output_index and sequence_number
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let v: Value = serde_json::from_str(&data)?;
let events = v.get("events").and_then(|e| e.as_array()).cloned().unwrap_or_default();
let mut items: Vec<(u64, u64)> = Vec::new();
for ev in events {
let data = ev.get("data");
if let Some(d) = data {
let out = d.get("output_index").and_then(|x| x.as_u64());
let seq = d.get("sequence_number").and_then(|x| x.as_u64());
if let (Some(out), Some(seq)) = (out, seq) {
items.push((out, seq));
}
}
}
items.sort();
Ok(items)
}
#[derive(Debug, Deserialize)]
struct InsertLog {
seq: u64,
ordered: bool,
req: u64,
out: u64,
item_seq: u64,
}
fn parse_tui_inserts(path: &Path) -> Result<Vec<InsertLog>> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let re = Regex::new(r"insert window: seq=(?P<seq>\d+) \((?P<kind>[OU]):(?:req=(?P<req>\d+) out=(?P<out>\d+) seq=(?P<iseq>\d+)|(?P<uval>\d+))\)").unwrap();
let mut out = Vec::new();
for line in text.lines() {
if let Some(caps) = re.captures(line) {
let seq: u64 = caps.name("seq").unwrap().as_str().parse().unwrap_or(0);
let ordered = &caps["kind"] == "O";
let (req, out_idx, item_seq) = if ordered {
let req = caps.name("req").unwrap().as_str().parse().unwrap_or(0);
let out_idx = caps.name("out").unwrap().as_str().parse().unwrap_or(0);
let iseq = caps.name("iseq").unwrap().as_str().parse().unwrap_or(0);
(req, out_idx, iseq)
} else {
(0, 0, caps.name("uval").unwrap().as_str().parse().unwrap_or(0))
};
out.push(InsertLog { seq, ordered, req, out: out_idx, item_seq });
}
}
Ok(out)
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
eprintln!("order-replay: usage: order_replay <response.json> <codex-tui.log>");
let response = args.next().context("missing response.json path")?;
let log = args.next().context("missing codex-tui.log path")?;
let expected = parse_response_expected(Path::new(&response))?;
let actual = parse_tui_inserts(Path::new(&log))?;
println!("Expected (first 20):");
for (i, (out, seq)) in expected.iter().take(20).enumerate() {
println!(" {:>3}: out={} seq={}", i, out, seq);
}
println!("\nActual inserts (first 40):");
for (i, log) in actual.iter().take(40).enumerate() {
if log.ordered {
println!(" {:>3}: O:req={} out={} seq={} (raw={})", i, log.req, log.out, log.item_seq, log.seq);
} else {
println!(" {:>3}: U:{}", i, log.item_seq);
}
}
// Quick check: find first O:req=1 out=2 and O:req=1 out=1 positions
let pos_out1 = actual.iter().position(|l| l.ordered && l.req == 1 && l.out == 1);
let pos_out2 = actual.iter().position(|l| l.ordered && l.req == 1 && l.out == 2);
if let (Some(p1), Some(p2)) = (pos_out1, pos_out2) {
println!("\nCheck: first out=1 at {}, first out=2 at {} => {}", p1, p2, if p1 < p2 {"OK"} else {"WRONG"});
} else {
println!("\nCheck: missing ordered inserts for out=1 or out=2 in req=1");
}
Ok(())
}
+129
View File
@@ -0,0 +1,129 @@
use std::path::PathBuf;
use code_common::CliConfigOverrides;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use code_core::exec_env::create_env;
use code_core::landlock::spawn_command_under_linux_sandbox;
use code_core::seatbelt::spawn_command_under_seatbelt;
use code_core::spawn::StdioPolicy;
use code_protocol::config_types::SandboxMode;
use crate::LandlockCommand;
use crate::SeatbeltCommand;
use crate::exit_status::handle_exit_status;
pub async fn run_command_under_seatbelt(
command: SeatbeltCommand,
code_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
let SeatbeltCommand {
full_auto,
config_overrides,
command,
} = command;
run_command_under_sandbox(
full_auto,
command,
config_overrides,
code_linux_sandbox_exe,
SandboxType::Seatbelt,
)
.await
}
pub async fn run_command_under_landlock(
command: LandlockCommand,
code_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
let LandlockCommand {
full_auto,
config_overrides,
command,
} = command;
run_command_under_sandbox(
full_auto,
command,
config_overrides,
code_linux_sandbox_exe,
SandboxType::Landlock,
)
.await
}
enum SandboxType {
Seatbelt,
Landlock,
}
async fn run_command_under_sandbox(
full_auto: bool,
command: Vec<String>,
config_overrides: CliConfigOverrides,
code_linux_sandbox_exe: Option<PathBuf>,
sandbox_type: SandboxType,
) -> anyhow::Result<()> {
let sandbox_mode = create_sandbox_mode(full_auto);
let config = Config::load_with_cli_overrides(
config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?,
ConfigOverrides {
sandbox_mode: Some(sandbox_mode),
code_linux_sandbox_exe,
..Default::default()
},
)?;
// In practice, this should be `std::env::current_dir()` because this CLI
// does not support `--cwd`, but let's use the config value for consistency.
let cwd = config.cwd.clone();
// For now, we always use the same cwd for both the command and the
// sandbox policy. In the future, we could add a CLI option to set them
// separately.
let sandbox_policy_cwd = cwd.clone();
let stdio_policy = StdioPolicy::Inherit;
let env = create_env(&config.shell_environment_policy);
let mut child = match sandbox_type {
SandboxType::Seatbelt => {
spawn_command_under_seatbelt(
command,
cwd,
&config.sandbox_policy,
sandbox_policy_cwd.as_path(),
stdio_policy,
env,
)
.await?
}
SandboxType::Landlock => {
#[expect(clippy::expect_used)]
let code_linux_sandbox_exe = config
.code_linux_sandbox_exe
.expect("codex-linux-sandbox executable not found");
spawn_command_under_linux_sandbox(
code_linux_sandbox_exe,
command,
cwd,
&config.sandbox_policy,
sandbox_policy_cwd.as_path(),
stdio_policy,
env,
)
.await?
}
};
let status = child.wait().await?;
handle_exit_status(status);
}
pub fn create_sandbox_mode(full_auto: bool) -> SandboxMode {
if full_auto {
SandboxMode::WorkspaceWrite
} else {
SandboxMode::ReadOnly
}
}
+23
View File
@@ -0,0 +1,23 @@
#[cfg(unix)]
pub(crate) fn handle_exit_status(status: std::process::ExitStatus) -> ! {
use std::os::unix::process::ExitStatusExt;
// Use ExitStatus to derive the exit code.
if let Some(code) = status.code() {
std::process::exit(code);
} else if let Some(signal) = status.signal() {
std::process::exit(128 + signal);
} else {
std::process::exit(1);
}
}
#[cfg(windows)]
pub(crate) fn handle_exit_status(status: std::process::ExitStatus) -> ! {
if let Some(code) = status.code() {
std::process::exit(code);
} else {
// Rare on Windows, but if it happens: use fallback code.
std::process::exit(1);
}
}
+35
View File
@@ -0,0 +1,35 @@
pub mod debug_sandbox;
mod exit_status;
pub mod login;
pub mod proto;
use clap::Parser;
use code_common::CliConfigOverrides;
#[derive(Debug, Parser)]
pub struct SeatbeltCommand {
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
/// Full command args to run under seatbelt.
#[arg(trailing_var_arg = true)]
pub command: Vec<String>,
}
#[derive(Debug, Parser)]
pub struct LandlockCommand {
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
/// Full command args to run under landlock.
#[arg(trailing_var_arg = true)]
pub command: Vec<String>,
}
+171
View File
@@ -0,0 +1,171 @@
use std::path::PathBuf;
use clap::Parser;
use code_common::CliConfigOverrides;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use code_core::ModelClient;
use code_core::ModelProviderInfo;
use code_core::AuthManager;
use code_core::Prompt;
use code_core::TextFormat;
use code_app_server_protocol::AuthMode;
use code_protocol::models::{ContentItem, ResponseItem};
use futures::StreamExt;
#[derive(Debug, Parser)]
pub struct LlmCli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
pub cmd: LlmSubcommand,
}
#[derive(Debug, clap::Subcommand)]
pub enum LlmSubcommand {
/// Send a one-off structured request to the model (side-channel; no TUI events)
Request(RequestArgs),
}
#[derive(Debug, Parser)]
pub struct RequestArgs {
/// Developer message to prepend (kept separate from system instructions)
#[arg(long)]
pub developer: String,
/// Primary user message/content
#[arg(long)]
pub message: String,
/// `text.format.type` (e.g. json_schema)
#[arg(long = "format-type", default_value = "json_schema")]
pub format_type: String,
/// Optional `text.format.name`
#[arg(long = "format-name")]
pub format_name: Option<String>,
/// Set `text.format.strict`
#[arg(long = "format-strict", default_value_t = true)]
pub format_strict: bool,
/// Inline JSON for the schema (mutually exclusive with --schema-file)
#[arg(long = "schema-json")]
pub schema_json: Option<String>,
/// Path to a JSON schema file (mutually exclusive with --schema-json)
#[arg(long = "schema-file")]
pub schema_file: Option<PathBuf>,
/// Optional model override (e.g. gpt-4.1, gpt-5)
#[arg(long)]
pub model: Option<String>,
}
pub async fn run_llm(opts: LlmCli) -> anyhow::Result<()> {
match opts.cmd {
LlmSubcommand::Request(req) => run_llm_request(opts.config_overrides, req).await,
}
}
async fn run_llm_request(
cli_overrides: CliConfigOverrides,
args: RequestArgs,
) -> anyhow::Result<()> {
let overrides_vec = cli_overrides.parse_overrides().map_err(anyhow::Error::msg)?;
let overrides = if let Some(model) = &args.model {
ConfigOverrides { model: Some(model.clone()), ..ConfigOverrides::default() }
} else { ConfigOverrides::default() };
let config = Config::load_with_cli_overrides(overrides_vec, overrides)?;
// Build Prompt with custom developer + user messages, no extra tools
let mut input: Vec<ResponseItem> = Vec::new();
input.push(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText { text: args.developer.clone() }],
});
input.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: args.message.clone() }],
});
// Resolve schema
let schema_val: Option<serde_json::Value> = if let Some(s) = &args.schema_json {
Some(serde_json::from_str::<serde_json::Value>(s)?)
} else if let Some(p) = &args.schema_file {
let data = std::fs::read_to_string(p)?;
Some(serde_json::from_str::<serde_json::Value>(&data)?)
} else {
None
};
let text_format = TextFormat {
r#type: args.format_type.clone(),
name: args.format_name.clone(),
strict: Some(args.format_strict),
schema: schema_val,
};
let mut prompt = Prompt::default();
prompt.input = input;
prompt.store = true;
prompt.user_instructions = None;
prompt.status_items = vec![];
prompt.base_instructions_override = None;
prompt.text_format = Some(text_format);
// Auth + provider
let auth_mgr = AuthManager::shared_with_mode_and_originator(
config.code_home.clone(),
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let provider: ModelProviderInfo = config.model_provider.clone();
let client = ModelClient::new(
std::sync::Arc::new(config.clone()),
Some(auth_mgr),
None,
provider,
config.model_reasoning_effort,
config.model_reasoning_summary,
config.model_text_verbosity,
uuid::Uuid::new_v4(),
std::sync::Arc::new(std::sync::Mutex::new(code_core::debug_logger::DebugLogger::new(false)?)),
);
// Collect the assistant message text from the stream (no TUI events)
let mut stream = client.stream(&prompt).await?;
let mut final_text: String = String::new();
tracing::info!("LLM: created");
while let Some(ev) = stream.next().await {
let ev = ev?;
match ev {
code_core::ResponseEvent::ReasoningSummaryDelta { delta, .. } => { tracing::info!(target: "llm", "thinking: {}", delta); }
code_core::ResponseEvent::ReasoningContentDelta { delta, .. } => { tracing::info!(target: "llm", "reasoning: {}", delta); }
code_core::ResponseEvent::OutputItemDone { item, .. } => {
if let ResponseItem::Message { content, .. } = item {
for c in content {
if let ContentItem::OutputText { text } = c {
final_text.push_str(&text);
}
}
}
}
code_core::ResponseEvent::OutputTextDelta { delta, .. } => {
tracing::info!(target: "llm", "delta: {}", delta);
// For completeness, but we only print at the end to stay simple
final_text.push_str(&delta);
}
code_core::ResponseEvent::Completed { .. } => { tracing::info!("LLM: completed"); break; }
_ => {}
}
}
println!("{}", final_text);
Ok(())
}
+227
View File
@@ -0,0 +1,227 @@
use code_app_server_protocol::AuthMode;
use code_common::CliConfigOverrides;
use code_core::CodexAuth;
use code_core::auth::CLIENT_ID;
use code_core::auth::OPENAI_API_KEY_ENV_VAR;
use code_core::auth::login_with_api_key;
use code_core::auth::logout;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use code_login::ServerOptions;
use code_login::run_device_code_login;
use code_login::run_login_server;
use std::env;
use std::io::IsTerminal;
use std::io::Read;
use std::path::PathBuf;
pub async fn login_with_chatgpt(code_home: PathBuf, originator: String) -> std::io::Result<()> {
let opts = ServerOptions::new(code_home, CLIENT_ID.to_string(), originator);
let server = run_login_server(opts)?;
eprintln!(
"Starting local login server on http://localhost:{}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{}",
server.actual_port, server.auth_url,
);
server.block_until_done().await
}
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match login_with_chatgpt(
config.code_home,
config.responses_originator_header.clone(),
)
.await
{
Ok(_) => {
eprintln!("Successfully logged in");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error logging in: {e}");
std::process::exit(1);
}
}
}
pub async fn run_login_with_api_key(
cli_config_overrides: CliConfigOverrides,
api_key: String,
) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match login_with_api_key(&config.code_home, &api_key) {
Ok(_) => {
eprintln!("Successfully logged in");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error logging in: {e}");
std::process::exit(1);
}
}
}
pub fn read_api_key_from_stdin() -> String {
let mut stdin = std::io::stdin();
if stdin.is_terminal() {
eprintln!(
"--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`."
);
std::process::exit(1);
}
eprintln!("Reading API key from stdin...");
let mut buffer = String::new();
if let Err(err) = stdin.read_to_string(&mut buffer) {
eprintln!("Failed to read API key from stdin: {err}");
std::process::exit(1);
}
let api_key = buffer.trim().to_string();
if api_key.is_empty() {
eprintln!("No API key provided via stdin.");
std::process::exit(1);
}
api_key
}
/// Login using the OAuth device code flow.
pub async fn run_login_with_device_code(
cli_config_overrides: CliConfigOverrides,
issuer_base_url: Option<String>,
client_id: Option<String>,
) -> ! {
let config = load_config_or_exit(cli_config_overrides);
let mut opts = ServerOptions::new(
config.code_home,
client_id.unwrap_or(CLIENT_ID.to_string()),
config.responses_originator_header.clone(),
);
if let Some(iss) = issuer_base_url {
opts.issuer = iss;
}
match run_device_code_login(opts).await {
Ok(()) => {
eprintln!("Successfully logged in");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error logging in with device code: {e}");
std::process::exit(1);
}
}
}
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match CodexAuth::from_code_home(
&config.code_home,
AuthMode::ApiKey,
&config.responses_originator_header,
) {
Ok(Some(auth)) => match auth.mode {
AuthMode::ApiKey => match auth.get_token().await {
Ok(api_key) => {
eprintln!("Logged in using an API key - {}", safe_format_key(&api_key));
if let Ok(env_api_key) = env::var(OPENAI_API_KEY_ENV_VAR) {
if env_api_key == api_key {
eprintln!(
" API loaded from OPENAI_API_KEY environment variable or .env file"
);
}
}
std::process::exit(0);
}
Err(e) => {
eprintln!("Unexpected error retrieving API key: {e}");
std::process::exit(1);
}
},
AuthMode::ChatGPT => {
eprintln!("Logged in using ChatGPT");
std::process::exit(0);
}
},
Ok(None) => {
eprintln!("Not logged in");
std::process::exit(1);
}
Err(e) => {
eprintln!("Error checking login status: {e}");
std::process::exit(1);
}
}
}
pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match logout(&config.code_home) {
Ok(true) => {
eprintln!("Successfully logged out");
std::process::exit(0);
}
Ok(false) => {
eprintln!("Not logged in");
std::process::exit(0);
}
Err(e) => {
eprintln!("Error logging out: {e}");
std::process::exit(1);
}
}
}
fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config {
let cli_overrides = match cli_config_overrides.parse_overrides() {
Ok(v) => v,
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
let config_overrides = ConfigOverrides::default();
match Config::load_with_cli_overrides(cli_overrides, config_overrides) {
Ok(config) => config,
Err(e) => {
eprintln!("Error loading configuration: {e}");
std::process::exit(1);
}
}
}
fn safe_format_key(key: &str) -> String {
if key.len() <= 13 {
return "***".to_string();
}
let prefix = &key[..8];
let suffix = &key[key.len() - 5..];
format!("{prefix}***{suffix}")
}
#[cfg(test)]
mod tests {
use super::safe_format_key;
#[test]
fn formats_long_key() {
let key = "sk-proj-1234567890ABCDE";
assert_eq!(safe_format_key(key), "sk-proj-***ABCDE");
}
#[test]
fn short_key_returns_stars() {
let key = "sk-proj-12345";
assert_eq!(safe_format_key(key), "***");
}
}
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
use std::collections::HashMap;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use code_common::CliConfigOverrides;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use code_core::config::find_code_home;
use code_core::config::load_global_mcp_servers;
use code_core::config::write_global_mcp_servers;
use code_core::config_types::McpServerConfig;
use code_core::config_types::McpServerTransportConfig;
/// [experimental] Launch Codex as an MCP server or manage configured MCP servers.
///
/// Subcommands:
/// - `serve` — run the MCP server on stdio
/// - `list` — list configured servers (with `--json`)
/// - `get` — show a single server (with `--json`)
/// - `add` — add a server launcher entry to `~/.code/config.toml` (Code also reads legacy `~/.codex/config.toml`)
/// - `remove` — delete a server entry
#[derive(Debug, clap::Parser)]
pub struct McpCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
pub subcommand: McpSubcommand,
}
#[derive(Debug, clap::Subcommand)]
pub enum McpSubcommand {
/// [experimental] List configured MCP servers.
List(ListArgs),
/// [experimental] Show details for a configured MCP server.
Get(GetArgs),
/// [experimental] Add a global MCP server entry.
Add(AddArgs),
/// [experimental] Remove a global MCP server entry.
Remove(RemoveArgs),
}
#[derive(Debug, clap::Parser)]
pub struct ListArgs {
/// Output the configured servers as JSON.
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Parser)]
pub struct GetArgs {
/// Name of the MCP server to display.
pub name: String,
/// Output the server configuration as JSON.
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Parser)]
pub struct AddArgs {
/// Name for the MCP server configuration.
pub name: String,
/// Environment variables to set when launching the server.
#[arg(long, value_parser = parse_env_pair, value_name = "KEY=VALUE")]
pub env: Vec<(String, String)>,
/// Command to launch the MCP server.
#[arg(trailing_var_arg = true, num_args = 1..)]
pub command: Vec<String>,
}
#[derive(Debug, clap::Parser)]
pub struct RemoveArgs {
/// Name of the MCP server configuration to remove.
pub name: String,
}
impl McpCli {
pub async fn run(self) -> Result<()> {
let McpCli {
config_overrides,
subcommand,
} = self;
match subcommand {
McpSubcommand::List(args) => {
run_list(&config_overrides, args)?;
}
McpSubcommand::Get(args) => {
run_get(&config_overrides, args)?;
}
McpSubcommand::Add(args) => {
run_add(&config_overrides, args)?;
}
McpSubcommand::Remove(args) => {
run_remove(&config_overrides, args)?;
}
}
Ok(())
}
}
fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> {
// Validate any provided overrides even though they are not currently applied.
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
let AddArgs { name, env, command } = add_args;
validate_server_name(&name)?;
let mut command_parts = command.into_iter();
let command_bin = command_parts
.next()
.ok_or_else(|| anyhow!("command is required"))?;
let command_args: Vec<String> = command_parts.collect();
let env_map = if env.is_empty() {
None
} else {
let mut map = HashMap::new();
for (key, value) in env {
map.insert(key, value);
}
Some(map)
};
let code_home = find_code_home().context("failed to resolve CODEX_HOME")?;
let mut servers = load_global_mcp_servers(&code_home)
.with_context(|| format!("failed to load MCP servers from {}", code_home.display()))?;
let new_entry = McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: command_bin,
args: command_args,
env: env_map,
},
startup_timeout_sec: None,
tool_timeout_sec: None,
};
servers.insert(name.clone(), new_entry);
write_global_mcp_servers(&code_home, &servers)
.with_context(|| format!("failed to write MCP servers to {}", code_home.display()))?;
println!("Added global MCP server '{name}'.");
Ok(())
}
fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> {
config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
let RemoveArgs { name } = remove_args;
validate_server_name(&name)?;
let code_home = find_code_home().context("failed to resolve CODEX_HOME")?;
let mut servers = load_global_mcp_servers(&code_home)
.with_context(|| format!("failed to load MCP servers from {}", code_home.display()))?;
let removed = servers.remove(&name).is_some();
if removed {
write_global_mcp_servers(&code_home, &servers)
.with_context(|| format!("failed to write MCP servers to {}", code_home.display()))?;
}
if removed {
println!("Removed global MCP server '{name}'.");
} else {
println!("No MCP server named '{name}' found.");
}
Ok(())
}
fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> {
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
.context("failed to load configuration")?;
let mut entries: Vec<_> = config.mcp_servers.iter().collect();
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
if list_args.json {
let json_entries: Vec<_> = entries
.into_iter()
.map(|(name, cfg)| {
let transport = match &cfg.transport {
McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({
"type": "stdio",
"command": command,
"args": args,
"env": env,
}),
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
serde_json::json!({
"type": "streamable_http",
"url": url,
"bearer_token": bearer_token,
})
}
};
serde_json::json!({
"name": name,
"transport": transport,
"startup_timeout_sec": cfg.startup_timeout_sec.map(|d| d.as_secs_f64()),
"tool_timeout_sec": cfg.tool_timeout_sec.map(|d| d.as_secs_f64()),
})
})
.collect();
let output = serde_json::to_string_pretty(&json_entries)?;
println!("{output}");
return Ok(());
}
if entries.is_empty() {
println!("No MCP servers configured yet. Try `codex mcp add my-tool -- my-command`.");
return Ok(());
}
let mut stdio_rows: Vec<[String; 4]> = Vec::new();
let mut http_rows: Vec<[String; 3]> = Vec::new();
for (name, cfg) in entries {
match &cfg.transport {
McpServerTransportConfig::Stdio { command, args, env } => {
let args_display = if args.is_empty() {
"-".to_string()
} else {
args.join(" ")
};
let env_display = match env.as_ref() {
None => "-".to_string(),
Some(map) if map.is_empty() => "-".to_string(),
Some(map) => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
}
};
stdio_rows.push([name.clone(), command.clone(), args_display, env_display]);
}
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
let has_bearer = if bearer_token.is_some() {
"True"
} else {
"False"
};
http_rows.push([name.clone(), url.clone(), has_bearer.into()]);
}
}
}
if !stdio_rows.is_empty() {
let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()];
for row in &stdio_rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.len());
}
}
println!(
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$}",
"Name",
"Command",
"Args",
"Env",
name_w = widths[0],
cmd_w = widths[1],
args_w = widths[2],
env_w = widths[3],
);
for row in &stdio_rows {
println!(
"{:<name_w$} {:<cmd_w$} {:<args_w$} {:<env_w$}",
row[0],
row[1],
row[2],
row[3],
name_w = widths[0],
cmd_w = widths[1],
args_w = widths[2],
env_w = widths[3],
);
}
}
if !stdio_rows.is_empty() && !http_rows.is_empty() {
println!();
}
if !http_rows.is_empty() {
let mut widths = ["Name".len(), "Url".len(), "Has Bearer Token".len()];
for row in &http_rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.len());
}
}
println!(
"{:<name_w$} {:<url_w$} {:<token_w$}",
"Name",
"Url",
"Has Bearer Token",
name_w = widths[0],
url_w = widths[1],
token_w = widths[2],
);
for row in &http_rows {
println!(
"{:<name_w$} {:<url_w$} {:<token_w$}",
row[0],
row[1],
row[2],
name_w = widths[0],
url_w = widths[1],
token_w = widths[2],
);
}
}
Ok(())
}
fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> {
let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?;
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
.context("failed to load configuration")?;
let Some(server) = config.mcp_servers.get(&get_args.name) else {
bail!("No MCP server named '{name}' found.", name = get_args.name);
};
if get_args.json {
let transport = match &server.transport {
McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({
"type": "stdio",
"command": command,
"args": args,
"env": env,
}),
McpServerTransportConfig::StreamableHttp { url, bearer_token } => serde_json::json!({
"type": "streamable_http",
"url": url,
"bearer_token": bearer_token,
}),
};
let output = serde_json::to_string_pretty(&serde_json::json!({
"name": get_args.name,
"transport": transport,
"startup_timeout_sec": server.startup_timeout_sec.map(|d| d.as_secs_f64()),
"tool_timeout_sec": server.tool_timeout_sec.map(|d| d.as_secs_f64()),
}))?;
println!("{output}");
return Ok(());
}
println!("{}", get_args.name);
match &server.transport {
McpServerTransportConfig::Stdio { command, args, env } => {
println!(" transport: stdio");
println!(" command: {command}");
let args_display = if args.is_empty() {
"-".to_string()
} else {
args.join(" ")
};
println!(" args: {args_display}");
let env_display = match env.as_ref() {
None => "-".to_string(),
Some(map) if map.is_empty() => "-".to_string(),
Some(map) => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
}
};
println!(" env: {env_display}");
}
McpServerTransportConfig::StreamableHttp { url, bearer_token } => {
println!(" transport: streamable_http");
println!(" url: {url}");
let token_display = bearer_token
.as_ref()
.map(|_| "<redacted>".to_string())
.unwrap_or_else(|| "-".to_string());
println!(" bearer_token: {token_display}");
}
}
if let Some(timeout) = server.startup_timeout_sec {
println!(" startup_timeout_sec: {:.3}", timeout.as_secs_f64());
}
if let Some(timeout) = server.tool_timeout_sec {
println!(" tool_timeout_sec: {:.3}", timeout.as_secs_f64());
}
println!(" remove: codex mcp remove {}", get_args.name);
Ok(())
}
fn parse_env_pair(raw: &str) -> Result<(String, String), String> {
let mut parts = raw.splitn(2, '=');
let key = parts
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| "environment entries must be in KEY=VALUE form".to_string())?;
let value = parts
.next()
.map(str::to_string)
.ok_or_else(|| "environment entries must be in KEY=VALUE form".to_string())?;
Ok((key.to_string(), value))
}
fn validate_server_name(name: &str) -> Result<()> {
let is_valid = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if is_valid {
Ok(())
} else {
bail!("invalid server name '{name}' (use letters, numbers, '-', '_')");
}
}
+142
View File
@@ -0,0 +1,142 @@
use std::io::IsTerminal;
use clap::Parser;
use code_common::CliConfigOverrides;
use code_core::AuthManager;
use code_core::ConversationManager;
use code_core::NewConversation;
use code_core::config::Config;
use code_core::config::ConfigOverrides;
use code_core::protocol::Event;
use code_core::protocol::EventMsg;
use code_core::protocol::Submission;
use code_protocol::protocol::SessionSource;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tracing::error;
use tracing::info;
#[derive(Debug, Parser)]
pub struct ProtoCli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
if std::io::stdin().is_terminal() {
anyhow::bail!("Protocol mode expects stdin to be a pipe, not a terminal");
}
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.init();
let ProtoCli { config_overrides } = opts;
let overrides_vec = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
// Use conversation_manager API to start a conversation
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.code_home.clone(),
code_login::AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Cli);
let NewConversation {
conversation_id: _,
conversation,
session_configured,
} = conversation_manager
.new_conversation(config.clone())
.await?;
// Simulate streaming the session_configured event.
let synthetic_event = Event {
// Fake id value.
id: "".to_string(),
event_seq: 0,
msg: EventMsg::SessionConfigured(session_configured),
order: None,
};
let session_configured_event = match serde_json::to_string(&synthetic_event) {
Ok(s) => s,
Err(e) => {
error!("Failed to serialize session_configured: {e}");
return Err(anyhow::Error::from(e));
}
};
println!("{session_configured_event}");
// Task that reads JSON lines from stdin and forwards to Submission Queue
let sq_fut = {
let conversation = conversation.clone();
async move {
let stdin = BufReader::new(tokio::io::stdin());
let mut lines = stdin.lines();
loop {
let result = tokio::select! {
_ = tokio::signal::ctrl_c() => {
break
},
res = lines.next_line() => res,
};
match result {
Ok(Some(line)) => {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<Submission>(line) {
Ok(sub) => {
if let Err(e) = conversation.submit_with_id(sub).await {
error!("{e:#}");
break;
}
}
Err(e) => {
error!("invalid submission: {e}");
}
}
}
_ => {
info!("Submission queue closed");
break;
}
}
}
}
};
// Task that reads events from the agent and prints them as JSON lines to stdout
let eq_fut = async move {
loop {
let event = tokio::select! {
_ = tokio::signal::ctrl_c() => break,
event = conversation.next_event() => event,
};
match event {
Ok(event) => {
let event_str = match serde_json::to_string(&event) {
Ok(s) => s,
Err(e) => {
error!("Failed to serialize event: {e}");
continue;
}
};
println!("{event_str}");
}
Err(e) => {
error!("{e:#}");
break;
}
}
}
info!("Event queue closed");
};
tokio::join!(sq_fut, eq_fut);
Ok(())
}
+95
View File
@@ -0,0 +1,95 @@
use std::path::Path;
use anyhow::Result;
use code_core::config::load_global_mcp_servers;
use code_core::config_types::McpServerTransportConfig;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn code_command(code_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::cargo_bin("codex")?;
cmd.env("CODEX_HOME", code_home);
Ok(cmd)
}
#[test]
fn add_and_remove_server_updates_global_config() -> Result<()> {
let code_home = TempDir::new()?;
let mut add_cmd = code_command(code_home.path())?;
add_cmd
.args(["mcp", "add", "docs", "--", "echo", "hello"])
.assert()
.success()
.stdout(contains("Added global MCP server 'docs'."));
let servers = load_global_mcp_servers(code_home.path())?;
assert_eq!(servers.len(), 1);
let docs = servers.get("docs").expect("server should exist");
match &docs.transport {
McpServerTransportConfig::Stdio { command, args, env } => {
assert_eq!(command, "echo");
assert_eq!(args, &vec!["hello".to_string()]);
assert!(env.is_none());
}
other => panic!("unexpected transport: {other:?}"),
}
let mut remove_cmd = code_command(code_home.path())?;
remove_cmd
.args(["mcp", "remove", "docs"])
.assert()
.success()
.stdout(contains("Removed global MCP server 'docs'."));
let servers = load_global_mcp_servers(code_home.path())?;
assert!(servers.is_empty());
let mut remove_again_cmd = code_command(code_home.path())?;
remove_again_cmd
.args(["mcp", "remove", "docs"])
.assert()
.success()
.stdout(contains("No MCP server named 'docs' found."));
let servers = load_global_mcp_servers(code_home.path())?;
assert!(servers.is_empty());
Ok(())
}
#[test]
fn add_with_env_preserves_key_order_and_values() -> Result<()> {
let code_home = TempDir::new()?;
let mut add_cmd = code_command(code_home.path())?;
add_cmd
.args([
"mcp",
"add",
"envy",
"--env",
"FOO=bar",
"--env",
"ALPHA=beta",
"--",
"python",
"server.py",
])
.assert()
.success();
let servers = load_global_mcp_servers(code_home.path())?;
let envy = servers.get("envy").expect("server should exist");
let env = match &envy.transport {
McpServerTransportConfig::Stdio { env: Some(env), .. } => env,
other => panic!("unexpected transport: {other:?}"),
};
assert_eq!(env.len(), 2);
assert_eq!(env.get("FOO"), Some(&"bar".to_string()));
assert_eq!(env.get("ALPHA"), Some(&"beta".to_string()));
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
use std::path::Path;
use anyhow::Result;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use serde_json::Value as JsonValue;
use serde_json::json;
use tempfile::TempDir;
fn code_command(code_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::cargo_bin("codex")?;
cmd.env("CODEX_HOME", code_home);
Ok(cmd)
}
#[test]
fn list_shows_empty_state() -> Result<()> {
let code_home = TempDir::new()?;
let mut cmd = code_command(code_home.path())?;
let output = cmd.args(["mcp", "list"]).output()?;
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout)?;
assert!(stdout.contains("No MCP servers configured yet."));
Ok(())
}
#[test]
fn list_and_get_render_expected_output() -> Result<()> {
let code_home = TempDir::new()?;
let mut add = code_command(code_home.path())?;
add.args([
"mcp",
"add",
"docs",
"--env",
"TOKEN=secret",
"--",
"docs-server",
"--port",
"4000",
])
.assert()
.success();
let mut list_cmd = code_command(code_home.path())?;
let list_output = list_cmd.args(["mcp", "list"]).output()?;
assert!(list_output.status.success());
let stdout = String::from_utf8(list_output.stdout)?;
assert!(stdout.contains("Name"));
assert!(stdout.contains("docs"));
assert!(stdout.contains("docs-server"));
assert!(stdout.contains("TOKEN=secret"));
let mut list_json_cmd = code_command(code_home.path())?;
let json_output = list_json_cmd.args(["mcp", "list", "--json"]).output()?;
assert!(json_output.status.success());
let stdout = String::from_utf8(json_output.stdout)?;
let parsed: JsonValue = serde_json::from_str(&stdout)?;
assert_eq!(
parsed,
json!([
{
"name": "docs",
"transport": {
"type": "stdio",
"command": "docs-server",
"args": [
"--port",
"4000"
],
"env": {
"TOKEN": "secret"
}
},
"startup_timeout_sec": null,
"tool_timeout_sec": null
}
]
)
);
let mut get_cmd = code_command(code_home.path())?;
let get_output = get_cmd.args(["mcp", "get", "docs"]).output()?;
assert!(get_output.status.success());
let stdout = String::from_utf8(get_output.stdout)?;
assert!(stdout.contains("docs"));
assert!(stdout.contains("transport: stdio"));
assert!(stdout.contains("command: docs-server"));
assert!(stdout.contains("args: --port 4000"));
assert!(stdout.contains("env: TOKEN=secret"));
assert!(stdout.contains("remove: codex mcp remove docs"));
let mut get_json_cmd = code_command(code_home.path())?;
get_json_cmd
.args(["mcp", "get", "docs", "--json"])
.assert()
.success()
.stdout(contains("\"name\": \"docs\""));
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
allow-expect-in-tests = true
allow-unwrap-in-tests = true
disallowed-methods = [
{ path = "ratatui::style::Color::Rgb", reason = "Use ANSI colors, which work better in various terminal themes." },
{ path = "ratatui::style::Color::Indexed", reason = "Use ANSI colors, which work better in various terminal themes." },
{ path = "ratatui::style::Stylize::white", reason = "Avoid hardcoding white; prefer default fg or dim/bold. Exception: Disable this rule if rendering over a hardcoded ANSI background." },
{ path = "ratatui::style::Stylize::black", reason = "Avoid hardcoding black; prefer default fg or dim/bold. Exception: Disable this rule if rendering over a hardcoded ANSI background." },
{ path = "ratatui::style::Stylize::yellow", reason = "Avoid yellow; prefer other colors in `tui/styles.md`." },
]
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "code-cloud-tasks-client"
version = { workspace = true }
edition = "2024"
[lib]
name = "code_cloud_tasks_client"
path = "src/lib.rs"
[lints]
workspace = true
[features]
default = ["online"]
online = ["dep:code-backend-client"]
mock = []
[dependencies]
anyhow = "1"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
diffy = "0.4.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2.0.12"
code-backend-client = { path = "../backend-client", optional = true }
code-git-apply = { path = "../git-apply" }
+158
View File
@@ -0,0 +1,158 @@
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
pub type Result<T> = std::result::Result<T, CloudTaskError>;
#[derive(Debug, thiserror::Error)]
pub enum CloudTaskError {
#[error("unimplemented: {0}")]
Unimplemented(&'static str),
#[error("http error: {0}")]
Http(String),
#[error("io error: {0}")]
Io(String),
#[error("{0}")]
Msg(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TaskId(pub String);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TaskStatus {
Pending,
Ready,
Applied,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskSummary {
pub id: TaskId,
pub title: String,
pub status: TaskStatus,
pub updated_at: DateTime<Utc>,
/// Backend environment identifier (when available)
pub environment_id: Option<String>,
/// Human-friendly environment label (when available)
pub environment_label: Option<String>,
pub summary: DiffSummary,
/// True when the backend reports this task as a code review.
#[serde(default)]
pub is_review: bool,
/// Number of assistant attempts (best-of-N), when reported by the backend.
#[serde(default)]
pub attempt_total: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AttemptStatus {
Pending,
InProgress,
Completed,
Failed,
Cancelled,
#[default]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TurnAttempt {
pub turn_id: String,
pub attempt_placement: Option<i64>,
pub created_at: Option<DateTime<Utc>>,
pub status: AttemptStatus,
pub diff: Option<String>,
pub messages: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApplyStatus {
Success,
Partial,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyOutcome {
pub applied: bool,
pub status: ApplyStatus,
pub message: String,
#[serde(default)]
pub skipped_paths: Vec<String>,
#[serde(default)]
pub conflict_paths: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreatedTask {
pub id: TaskId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DiffSummary {
pub files_changed: usize,
pub lines_added: usize,
pub lines_removed: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TaskText {
pub prompt: Option<String>,
pub messages: Vec<String>,
pub turn_id: Option<String>,
pub sibling_turn_ids: Vec<String>,
pub attempt_placement: Option<i64>,
pub attempt_status: AttemptStatus,
}
impl Default for TaskText {
fn default() -> Self {
Self {
prompt: None,
messages: Vec::new(),
turn_id: None,
sibling_turn_ids: Vec::new(),
attempt_placement: None,
attempt_status: AttemptStatus::Unknown,
}
}
}
#[async_trait::async_trait]
pub trait CloudBackend: Send + Sync {
async fn list_tasks(&self, env: Option<&str>) -> Result<Vec<TaskSummary>>;
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>>;
/// Return assistant output messages (no diff) when available.
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>>;
/// Return the creating prompt and assistant messages (when available).
async fn get_task_text(&self, id: TaskId) -> Result<TaskText>;
/// Return any sibling attempts (best-of-N) for the given assistant turn.
async fn list_sibling_attempts(
&self,
task: TaskId,
turn_id: String,
) -> Result<Vec<TurnAttempt>>;
/// Dry-run apply (preflight) that validates whether the patch would apply cleanly.
/// Never modifies the working tree. When `diff_override` is supplied, the provided diff is
/// used instead of re-fetching the task details so callers can apply alternate attempts.
async fn apply_task_preflight(
&self,
id: TaskId,
diff_override: Option<String>,
) -> Result<ApplyOutcome>;
async fn apply_task(&self, id: TaskId, diff_override: Option<String>) -> Result<ApplyOutcome>;
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
best_of_n: usize,
) -> Result<CreatedTask>;
}
+769
View File
@@ -0,0 +1,769 @@
use crate::ApplyOutcome;
use crate::ApplyStatus;
use crate::AttemptStatus;
use crate::CloudBackend;
use crate::CloudTaskError;
use crate::DiffSummary;
use crate::Result;
use crate::TaskId;
use crate::TaskStatus;
use crate::TaskSummary;
use crate::TurnAttempt;
use crate::api::TaskText;
use chrono::DateTime;
use chrono::Utc;
use code_backend_client as backend;
use code_backend_client::CodeTaskDetailsResponseExt;
#[derive(Clone)]
pub struct HttpClient {
pub base_url: String,
backend: backend::Client,
}
impl HttpClient {
pub fn new(base_url: impl Into<String>) -> anyhow::Result<Self> {
let base_url = base_url.into();
let backend = backend::Client::new(base_url.clone())?;
Ok(Self { base_url, backend })
}
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.backend = self.backend.clone().with_bearer_token(token);
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
self.backend = self.backend.clone().with_user_agent(ua);
self
}
pub fn with_chatgpt_account_id(mut self, account_id: impl Into<String>) -> Self {
self.backend = self.backend.clone().with_chatgpt_account_id(account_id);
self
}
fn tasks_api(&self) -> api::Tasks<'_> {
api::Tasks::new(self)
}
fn attempts_api(&self) -> api::Attempts<'_> {
api::Attempts::new(self)
}
fn apply_api(&self) -> api::Apply<'_> {
api::Apply::new(self)
}
}
#[async_trait::async_trait]
impl CloudBackend for HttpClient {
async fn list_tasks(&self, env: Option<&str>) -> Result<Vec<TaskSummary>> {
self.tasks_api().list(env).await
}
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
self.tasks_api().diff(id).await
}
async fn get_task_messages(&self, id: TaskId) -> Result<Vec<String>> {
self.tasks_api().messages(id).await
}
async fn get_task_text(&self, id: TaskId) -> Result<TaskText> {
self.tasks_api().task_text(id).await
}
async fn list_sibling_attempts(
&self,
task: TaskId,
turn_id: String,
) -> Result<Vec<TurnAttempt>> {
self.attempts_api().list(task, turn_id).await
}
async fn apply_task(&self, id: TaskId, diff_override: Option<String>) -> Result<ApplyOutcome> {
self.apply_api().run(id, diff_override, false).await
}
async fn apply_task_preflight(
&self,
id: TaskId,
diff_override: Option<String>,
) -> Result<ApplyOutcome> {
self.apply_api().run(id, diff_override, true).await
}
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
best_of_n: usize,
) -> Result<crate::CreatedTask> {
self.tasks_api()
.create(env_id, prompt, git_ref, qa_mode, best_of_n)
.await
}
}
mod api {
use super::*;
use serde_json::Value;
use std::cmp::Ordering;
use std::collections::HashMap;
pub(crate) struct Tasks<'a> {
base_url: &'a str,
backend: &'a backend::Client,
}
impl<'a> Tasks<'a> {
pub(crate) fn new(client: &'a HttpClient) -> Self {
Self {
base_url: &client.base_url,
backend: &client.backend,
}
}
pub(crate) async fn list(&self, env: Option<&str>) -> Result<Vec<TaskSummary>> {
let resp = self
.backend
.list_tasks(Some(20), Some("current"), env)
.await
.map_err(|e| CloudTaskError::Http(format!("list_tasks failed: {e}")))?;
let tasks: Vec<TaskSummary> = resp
.items
.into_iter()
.map(map_task_list_item_to_summary)
.collect();
append_error_log(&format!(
"http.list_tasks: env={} items={}",
env.unwrap_or("<all>"),
tasks.len()
));
Ok(tasks)
}
pub(crate) async fn diff(&self, id: TaskId) -> Result<Option<String>> {
let (details, body, ct) = self
.details_with_body(&id.0)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
if let Some(diff) = details.unified_diff() {
return Ok(Some(diff));
}
let _ = (body, ct);
Ok(None)
}
pub(crate) async fn messages(&self, id: TaskId) -> Result<Vec<String>> {
let (details, body, ct) = self
.details_with_body(&id.0)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
let mut msgs = details.assistant_text_messages();
if msgs.is_empty() {
msgs.extend(extract_assistant_messages_from_body(&body));
}
if !msgs.is_empty() {
return Ok(msgs);
}
if let Some(err) = details.assistant_error_message() {
return Ok(vec![format!("Task failed: {err}")]);
}
let url = match details_path(self.base_url, &id.0) {
Some(url) => url,
None => format!("{}/api/codex/tasks/{}", self.base_url, id.0),
};
Err(CloudTaskError::Http(format!(
"No assistant text messages in response. GET {url}; content-type={ct}; body={body}"
)))
}
pub(crate) async fn task_text(&self, id: TaskId) -> Result<TaskText> {
let (details, body, _ct) = self
.details_with_body(&id.0)
.await
.map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?;
let prompt = details.user_text_prompt();
let mut messages = details.assistant_text_messages();
if messages.is_empty() {
messages.extend(extract_assistant_messages_from_body(&body));
}
let assistant_turn = details.current_assistant_turn.as_ref();
let turn_id = assistant_turn.and_then(|turn| turn.id.clone());
let sibling_turn_ids = assistant_turn
.map(|turn| turn.sibling_turn_ids.clone())
.unwrap_or_default();
let attempt_placement = assistant_turn.and_then(|turn| turn.attempt_placement);
let attempt_status = attempt_status_from_str(
assistant_turn.and_then(|turn| turn.turn_status.as_deref()),
);
Ok(TaskText {
prompt,
messages,
turn_id,
sibling_turn_ids,
attempt_placement,
attempt_status,
})
}
pub(crate) async fn create(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
best_of_n: usize,
) -> Result<crate::CreatedTask> {
let mut input_items: Vec<serde_json::Value> = Vec::new();
input_items.push(serde_json::json!({
"type": "message",
"role": "user",
"content": [{ "content_type": "text", "text": prompt }]
}));
if let Ok(diff) = std::env::var("CODEX_STARTING_DIFF")
&& !diff.is_empty()
{
input_items.push(serde_json::json!({
"type": "pre_apply_patch",
"output_diff": { "diff": diff }
}));
}
let mut request_body = serde_json::json!({
"new_task": {
"environment_id": env_id,
"branch": git_ref,
"run_environment_in_qa_mode": qa_mode,
},
"input_items": input_items,
});
if best_of_n > 1
&& let Some(obj) = request_body.as_object_mut()
{
obj.insert(
"metadata".to_string(),
serde_json::json!({ "best_of_n": best_of_n }),
);
}
match self.backend.create_task(request_body).await {
Ok(id) => {
append_error_log(&format!(
"new_task: created id={id} env={} prompt_chars={}",
env_id,
prompt.chars().count()
));
Ok(crate::CreatedTask { id: TaskId(id) })
}
Err(e) => {
append_error_log(&format!(
"new_task: create failed env={} prompt_chars={}: {}",
env_id,
prompt.chars().count(),
e
));
Err(CloudTaskError::Http(format!("create_task failed: {e}")))
}
}
}
async fn details_with_body(
&self,
id: &str,
) -> anyhow::Result<(backend::CodeTaskDetailsResponse, String, String)> {
let (parsed, body, ct) = self.backend.get_task_details_with_body(id).await?;
Ok((parsed, body, ct))
}
}
pub(crate) struct Attempts<'a> {
backend: &'a backend::Client,
}
impl<'a> Attempts<'a> {
pub(crate) fn new(client: &'a HttpClient) -> Self {
Self {
backend: &client.backend,
}
}
pub(crate) async fn list(&self, task: TaskId, turn_id: String) -> Result<Vec<TurnAttempt>> {
let resp = self
.backend
.list_sibling_turns(&task.0, &turn_id)
.await
.map_err(|e| CloudTaskError::Http(format!("list_sibling_turns failed: {e}")))?;
let mut attempts: Vec<TurnAttempt> = resp
.sibling_turns
.iter()
.filter_map(turn_attempt_from_map)
.collect();
attempts.sort_by(compare_attempts);
Ok(attempts)
}
}
pub(crate) struct Apply<'a> {
backend: &'a backend::Client,
}
impl<'a> Apply<'a> {
pub(crate) fn new(client: &'a HttpClient) -> Self {
Self {
backend: &client.backend,
}
}
pub(crate) async fn run(
&self,
task_id: TaskId,
diff_override: Option<String>,
preflight: bool,
) -> Result<ApplyOutcome> {
let id = task_id.0.clone();
let diff = match diff_override {
Some(diff) => diff,
None => {
let details = self.backend.get_task_details(&id).await.map_err(|e| {
CloudTaskError::Http(format!("get_task_details failed: {e}"))
})?;
details.unified_diff().ok_or_else(|| {
CloudTaskError::Msg(format!("No diff available for task {id}"))
})?
}
};
if !is_unified_diff(&diff) {
let summary = summarize_patch_for_logging(&diff);
let mode = if preflight { "preflight" } else { "apply" };
append_error_log(&format!(
"apply_error: id={id} mode={mode} format=non-unified; {summary}"
));
return Ok(ApplyOutcome {
applied: false,
status: ApplyStatus::Error,
message: "Expected unified git diff; backend returned an incompatible format."
.to_string(),
skipped_paths: Vec::new(),
conflict_paths: Vec::new(),
});
}
let req = code_git_apply::ApplyGitRequest {
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
diff: diff.clone(),
revert: false,
preflight,
};
let r = code_git_apply::apply_git_patch(&req)
.map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?;
let status = if r.exit_code == 0 {
ApplyStatus::Success
} else if !r.applied_paths.is_empty() || !r.conflicted_paths.is_empty() {
ApplyStatus::Partial
} else {
ApplyStatus::Error
};
let applied = matches!(status, ApplyStatus::Success) && !preflight;
let message = if preflight {
match status {
ApplyStatus::Success => {
format!("Preflight passed for task {id} (applies cleanly)")
}
ApplyStatus::Partial => format!(
"Preflight: patch does not fully apply for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
ApplyStatus::Error => format!(
"Preflight failed for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
}
} else {
match status {
ApplyStatus::Success => format!(
"Applied task {id} locally ({} files)",
r.applied_paths.len()
),
ApplyStatus::Partial => format!(
"Apply partially succeeded for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
ApplyStatus::Error => format!(
"Apply failed for task {id} (applied={}, skipped={}, conflicts={})",
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len()
),
}
};
if matches!(status, ApplyStatus::Partial | ApplyStatus::Error)
|| (preflight && !matches!(status, ApplyStatus::Success))
{
let mut log = String::new();
let summary = summarize_patch_for_logging(&diff);
let mode = if preflight { "preflight" } else { "apply" };
use std::fmt::Write as _;
let _ = writeln!(
&mut log,
"apply_result: mode={} id={} status={:?} applied={} skipped={} conflicts={} cmd={}",
mode,
id,
status,
r.applied_paths.len(),
r.skipped_paths.len(),
r.conflicted_paths.len(),
r.cmd_for_log
);
let _ = writeln!(
&mut log,
"stdout_tail=\n{}\nstderr_tail=\n{}",
tail(&r.stdout, 2000),
tail(&r.stderr, 2000)
);
let _ = writeln!(&mut log, "{summary}");
let _ = writeln!(
&mut log,
"----- PATCH BEGIN -----\n{diff}\n----- PATCH END -----"
);
append_error_log(&log);
}
Ok(ApplyOutcome {
applied,
status,
message,
skipped_paths: r.skipped_paths,
conflict_paths: r.conflicted_paths,
})
}
}
fn details_path(base_url: &str, id: &str) -> Option<String> {
if base_url.contains("/backend-api") {
Some(format!("{base_url}/wham/tasks/{id}"))
} else if base_url.contains("/api/codex") {
Some(format!("{base_url}/tasks/{id}"))
} else {
None
}
}
fn extract_assistant_messages_from_body(body: &str) -> Vec<String> {
let mut msgs = Vec::new();
if let Ok(full) = serde_json::from_str::<serde_json::Value>(body)
&& let Some(arr) = full
.get("current_assistant_turn")
.and_then(|v| v.get("worklog"))
.and_then(|v| v.get("messages"))
.and_then(|v| v.as_array())
{
for m in arr {
let is_assistant = m
.get("author")
.and_then(|a| a.get("role"))
.and_then(|r| r.as_str())
== Some("assistant");
if !is_assistant {
continue;
}
if let Some(parts) = m
.get("content")
.and_then(|c| c.get("parts"))
.and_then(|p| p.as_array())
{
for p in parts {
if let Some(s) = p.as_str() {
if !s.is_empty() {
msgs.push(s.to_string());
}
continue;
}
if let Some(obj) = p.as_object()
&& obj.get("content_type").and_then(|t| t.as_str()) == Some("text")
&& let Some(txt) = obj.get("text").and_then(|t| t.as_str())
{
msgs.push(txt.to_string());
}
}
}
}
}
msgs
}
fn turn_attempt_from_map(turn: &HashMap<String, Value>) -> Option<TurnAttempt> {
let turn_id = turn.get("id").and_then(Value::as_str)?.to_string();
let attempt_placement = turn.get("attempt_placement").and_then(Value::as_i64);
let created_at = parse_timestamp_value(turn.get("created_at"));
let status = attempt_status_from_str(turn.get("turn_status").and_then(Value::as_str));
let diff = extract_diff_from_turn(turn);
let messages = extract_assistant_messages_from_turn(turn);
Some(TurnAttempt {
turn_id,
attempt_placement,
created_at,
status,
diff,
messages,
})
}
fn compare_attempts(a: &TurnAttempt, b: &TurnAttempt) -> Ordering {
match (a.attempt_placement, b.attempt_placement) {
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => match (a.created_at, b.created_at) {
(Some(lhs), Some(rhs)) => lhs.cmp(&rhs),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => a.turn_id.cmp(&b.turn_id),
},
}
}
fn extract_diff_from_turn(turn: &HashMap<String, Value>) -> Option<String> {
let items = turn.get("output_items").and_then(Value::as_array)?;
for item in items {
match item.get("type").and_then(Value::as_str) {
Some("output_diff") => {
if let Some(diff) = item.get("diff").and_then(Value::as_str)
&& !diff.is_empty()
{
return Some(diff.to_string());
}
}
Some("pr") => {
if let Some(diff) = item
.get("output_diff")
.and_then(Value::as_object)
.and_then(|od| od.get("diff"))
.and_then(Value::as_str)
&& !diff.is_empty()
{
return Some(diff.to_string());
}
}
_ => {}
}
}
None
}
fn extract_assistant_messages_from_turn(turn: &HashMap<String, Value>) -> Vec<String> {
let mut msgs = Vec::new();
if let Some(items) = turn.get("output_items").and_then(Value::as_array) {
for item in items {
if item.get("type").and_then(Value::as_str) != Some("message") {
continue;
}
if let Some(content) = item.get("content").and_then(Value::as_array) {
for part in content {
if part.get("content_type").and_then(Value::as_str) == Some("text")
&& let Some(txt) = part.get("text").and_then(Value::as_str)
&& !txt.is_empty()
{
msgs.push(txt.to_string());
}
}
}
}
}
msgs
}
fn attempt_status_from_str(raw: Option<&str>) -> AttemptStatus {
match raw.unwrap_or_default() {
"failed" => AttemptStatus::Failed,
"completed" => AttemptStatus::Completed,
"in_progress" => AttemptStatus::InProgress,
"pending" => AttemptStatus::Pending,
_ => AttemptStatus::Pending,
}
}
fn parse_timestamp_value(v: Option<&Value>) -> Option<DateTime<Utc>> {
let ts = v?.as_f64()?;
let secs = ts as i64;
let nanos = ((ts - secs as f64) * 1_000_000_000.0) as u32;
Some(DateTime::<Utc>::from(
std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos),
))
}
fn map_task_list_item_to_summary(src: backend::TaskListItem) -> TaskSummary {
let status_display = src.task_status_display.as_ref();
TaskSummary {
id: TaskId(src.id),
title: src.title,
status: map_status(status_display),
updated_at: parse_updated_at(src.updated_at.as_ref()),
environment_id: None,
environment_label: env_label_from_status_display(status_display),
summary: diff_summary_from_status_display(status_display),
is_review: src
.pull_requests
.as_ref()
.is_some_and(|prs| !prs.is_empty()),
attempt_total: attempt_total_from_status_display(status_display),
}
}
fn map_status(v: Option<&HashMap<String, Value>>) -> TaskStatus {
if let Some(val) = v {
if let Some(turn) = val
.get("latest_turn_status_display")
.and_then(Value::as_object)
&& let Some(s) = turn.get("turn_status").and_then(Value::as_str)
{
return match s {
"failed" => TaskStatus::Error,
"completed" => TaskStatus::Ready,
"in_progress" => TaskStatus::Pending,
"pending" => TaskStatus::Pending,
"cancelled" => TaskStatus::Error,
_ => TaskStatus::Pending,
};
}
if let Some(state) = val.get("state").and_then(Value::as_str) {
return match state {
"pending" => TaskStatus::Pending,
"ready" => TaskStatus::Ready,
"applied" => TaskStatus::Applied,
"error" => TaskStatus::Error,
_ => TaskStatus::Pending,
};
}
}
TaskStatus::Pending
}
fn parse_updated_at(ts: Option<&f64>) -> DateTime<Utc> {
if let Some(v) = ts {
let secs = *v as i64;
let nanos = ((*v - secs as f64) * 1_000_000_000.0) as u32;
return DateTime::<Utc>::from(
std::time::UNIX_EPOCH + std::time::Duration::new(secs.max(0) as u64, nanos),
);
}
Utc::now()
}
fn env_label_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<String> {
let map = v?;
map.get("environment_label")
.and_then(Value::as_str)
.map(str::to_string)
}
fn diff_summary_from_status_display(v: Option<&HashMap<String, Value>>) -> DiffSummary {
let mut out = DiffSummary::default();
let Some(map) = v else { return out };
let latest = map
.get("latest_turn_status_display")
.and_then(Value::as_object);
let Some(latest) = latest else { return out };
if let Some(ds) = latest.get("diff_stats").and_then(Value::as_object) {
if let Some(n) = ds.get("files_modified").and_then(Value::as_i64) {
out.files_changed = n.max(0) as usize;
}
if let Some(n) = ds.get("lines_added").and_then(Value::as_i64) {
out.lines_added = n.max(0) as usize;
}
if let Some(n) = ds.get("lines_removed").and_then(Value::as_i64) {
out.lines_removed = n.max(0) as usize;
}
}
out
}
fn attempt_total_from_status_display(v: Option<&HashMap<String, Value>>) -> Option<usize> {
let map = v?;
let latest = map
.get("latest_turn_status_display")
.and_then(Value::as_object)?;
let siblings = latest.get("sibling_turn_ids").and_then(Value::as_array)?;
Some(siblings.len().saturating_add(1))
}
fn is_unified_diff(diff: &str) -> bool {
let t = diff.trim_start();
if t.starts_with("diff --git ") {
return true;
}
let has_dash_headers = diff.contains("\n--- ") && diff.contains("\n+++ ");
let has_hunk = diff.contains("\n@@ ") || diff.starts_with("@@ ");
has_dash_headers && has_hunk
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
s[s.len() - max..].to_string()
}
}
fn summarize_patch_for_logging(patch: &str) -> String {
let trimmed = patch.trim_start();
let kind = if trimmed.starts_with("*** Begin Patch") {
"codex-patch"
} else if trimmed.starts_with("diff --git ") || trimmed.contains("\n*** End Patch\n") {
"git-diff"
} else if trimmed.starts_with("@@ ") || trimmed.contains("\n@@ ") {
"unified-diff"
} else {
"unknown"
};
let lines = patch.lines().count();
let chars = patch.len();
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unknown>".to_string());
let head: String = patch.lines().take(20).collect::<Vec<&str>>().join("\n");
let head_trunc = if head.len() > 800 {
format!("{}…", &head[..800])
} else {
head
};
format!(
"patch_summary: kind={kind} lines={lines} chars={chars} cwd={cwd} ; head=\n{head_trunc}"
)
}
}
fn append_error_log(message: &str) {
let ts = Utc::now().to_rfc3339();
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("error.log")
{
use std::io::Write as _;
let _ = writeln!(f, "[{ts}] {message}");
}
}
+29
View File
@@ -0,0 +1,29 @@
mod api;
pub use api::ApplyOutcome;
pub use api::ApplyStatus;
pub use api::AttemptStatus;
pub use api::CloudBackend;
pub use api::CloudTaskError;
pub use api::CreatedTask;
pub use api::DiffSummary;
pub use api::Result;
pub use api::TaskId;
pub use api::TaskStatus;
pub use api::TaskSummary;
pub use api::TaskText;
pub use api::TurnAttempt;
#[cfg(feature = "mock")]
mod mock;
#[cfg(feature = "online")]
mod http;
#[cfg(feature = "mock")]
pub use mock::MockClient;
#[cfg(feature = "online")]
pub use http::HttpClient;
// Reusable apply engine now lives in the shared crate `codex-git-apply`.
+180
View File
@@ -0,0 +1,180 @@
use crate::ApplyOutcome;
use crate::AttemptStatus;
use crate::CloudBackend;
use crate::DiffSummary;
use crate::Result;
use crate::TaskId;
use crate::TaskStatus;
use crate::TaskSummary;
use crate::TurnAttempt;
use crate::api::TaskText;
use chrono::Utc;
#[derive(Clone, Default)]
pub struct MockClient;
#[async_trait::async_trait]
impl CloudBackend for MockClient {
async fn list_tasks(&self, _env: Option<&str>) -> Result<Vec<TaskSummary>> {
// Slightly vary content by env to aid tests that rely on the mock
let rows = match _env {
Some("env-A") => vec![("T-2000", "A: First", TaskStatus::Ready)],
Some("env-B") => vec![
("T-3000", "B: One", TaskStatus::Ready),
("T-3001", "B: Two", TaskStatus::Pending),
],
_ => vec![
("T-1000", "Update README formatting", TaskStatus::Ready),
("T-1001", "Fix clippy warnings in core", TaskStatus::Pending),
("T-1002", "Add contributing guide", TaskStatus::Ready),
],
};
let environment_id = _env.map(str::to_string);
let environment_label = match _env {
Some("env-A") => Some("Env A".to_string()),
Some("env-B") => Some("Env B".to_string()),
Some(other) => Some(other.to_string()),
None => Some("Global".to_string()),
};
let mut out = Vec::new();
for (id_str, title, status) in rows {
let id = TaskId(id_str.to_string());
let diff = mock_diff_for(&id);
let (a, d) = count_from_unified(&diff);
out.push(TaskSummary {
id,
title: title.to_string(),
status,
updated_at: Utc::now(),
environment_id: environment_id.clone(),
environment_label: environment_label.clone(),
summary: DiffSummary {
files_changed: 1,
lines_added: a,
lines_removed: d,
},
is_review: false,
attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }),
});
}
Ok(out)
}
async fn get_task_diff(&self, id: TaskId) -> Result<Option<String>> {
Ok(Some(mock_diff_for(&id)))
}
async fn get_task_messages(&self, _id: TaskId) -> Result<Vec<String>> {
Ok(vec![
"Mock assistant output: this task contains no diff.".to_string(),
])
}
async fn get_task_text(&self, _id: TaskId) -> Result<TaskText> {
Ok(TaskText {
prompt: Some("Why is there no diff?".to_string()),
messages: vec!["Mock assistant output: this task contains no diff.".to_string()],
turn_id: Some("mock-turn".to_string()),
sibling_turn_ids: Vec::new(),
attempt_placement: Some(0),
attempt_status: AttemptStatus::Completed,
})
}
async fn apply_task(&self, id: TaskId, _diff_override: Option<String>) -> Result<ApplyOutcome> {
Ok(ApplyOutcome {
applied: true,
status: crate::ApplyStatus::Success,
message: format!("Applied task {} locally (mock)", id.0),
skipped_paths: Vec::new(),
conflict_paths: Vec::new(),
})
}
async fn apply_task_preflight(
&self,
id: TaskId,
_diff_override: Option<String>,
) -> Result<ApplyOutcome> {
Ok(ApplyOutcome {
applied: false,
status: crate::ApplyStatus::Success,
message: format!("Preflight passed for task {} (mock)", id.0),
skipped_paths: Vec::new(),
conflict_paths: Vec::new(),
})
}
async fn list_sibling_attempts(
&self,
task: TaskId,
_turn_id: String,
) -> Result<Vec<TurnAttempt>> {
if task.0 == "T-1000" {
return Ok(vec![TurnAttempt {
turn_id: "T-1000-attempt-2".to_string(),
attempt_placement: Some(1),
created_at: Some(Utc::now()),
status: AttemptStatus::Completed,
diff: Some(mock_diff_for(&task)),
messages: vec!["Mock alternate attempt".to_string()],
}]);
}
Ok(Vec::new())
}
async fn create_task(
&self,
env_id: &str,
prompt: &str,
git_ref: &str,
qa_mode: bool,
best_of_n: usize,
) -> Result<crate::CreatedTask> {
let _ = (env_id, prompt, git_ref, qa_mode, best_of_n);
let id = format!("task_local_{}", chrono::Utc::now().timestamp_millis());
Ok(crate::CreatedTask { id: TaskId(id) })
}
}
fn mock_diff_for(id: &TaskId) -> String {
match id.0.as_str() {
"T-1000" => {
"diff --git a/README.md b/README.md\nindex 000000..111111 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,3 @@\n Intro\n-Hello\n+Hello, world!\n+Task: T-1000\n".to_string()
}
"T-1001" => {
"diff --git a/core/src/lib.rs b/core/src/lib.rs\nindex 000000..111111 100644\n--- a/core/src/lib.rs\n+++ b/core/src/lib.rs\n@@ -1,2 +1,1 @@\n-use foo;\n use bar;\n".to_string()
}
_ => {
"diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md\nindex 000000..111111 100644\n--- /dev/null\n+++ b/CONTRIBUTING.md\n@@ -0,0 +1,3 @@\n+## Contributing\n+Please open PRs.\n+Thanks!\n".to_string()
}
}
}
fn count_from_unified(diff: &str) -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
patch
.hunks()
.iter()
.flat_map(diffy::Hunk::lines)
.fold((0, 0), |(a, d), l| match l {
diffy::Line::Insert(_) => (a + 1, d),
diffy::Line::Delete(_) => (a, d + 1),
_ => (a, d),
})
} else {
let mut a = 0;
let mut d = 0;
for l in diff.lines() {
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
continue;
}
match l.as_bytes().first() {
Some(b'+') => a += 1,
Some(b'-') => d += 1,
_ => {}
}
}
(a, d)
}
}

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